ithrl 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.
- ithrl/__init__.py +1 -0
- ithrl/__main__.py +6 -0
- ithrl/adapters/__init__.py +49 -0
- ithrl/adapters/_jsonl.py +266 -0
- ithrl/adapters/codex/__init__.py +5 -0
- ithrl/adapters/codex/context.py +204 -0
- ithrl/adapters/codex/decisions.py +224 -0
- ithrl/adapters/codex/discovery.py +60 -0
- ithrl/adapters/codex/parser.py +191 -0
- ithrl/adapters/contracts.py +106 -0
- ithrl/adapters/copilot/__init__.py +5 -0
- ithrl/adapters/copilot/context.py +182 -0
- ithrl/adapters/copilot/decisions.py +294 -0
- ithrl/adapters/copilot/discovery.py +28 -0
- ithrl/adapters/copilot/parser.py +283 -0
- ithrl/adapters/discovery.py +220 -0
- ithrl/adapters/errors.py +52 -0
- ithrl/adapters/registry.py +433 -0
- ithrl/behavior.py +138 -0
- ithrl/calibration.py +434 -0
- ithrl/case/__init__.py +33 -0
- ithrl/case/contracts.py +88 -0
- ithrl/case/evidence.py +169 -0
- ithrl/case/filesystem.py +340 -0
- ithrl/case/source.py +208 -0
- ithrl/case/source_context.py +119 -0
- ithrl/case/source_storage.py +234 -0
- ithrl/case/storage.py +377 -0
- ithrl/cli/__init__.py +968 -0
- ithrl/cli/extract.py +478 -0
- ithrl/cli/native.py +484 -0
- ithrl/cli/output.py +786 -0
- ithrl/cli/paths.py +24 -0
- ithrl/cli/sampled.py +485 -0
- ithrl/cli/sampled_output.py +472 -0
- ithrl/cli/sampled_reduction.py +1036 -0
- ithrl/cli/sampled_session.py +342 -0
- ithrl/cli/shrink.py +523 -0
- ithrl/context.py +465 -0
- ithrl/decisions.py +271 -0
- ithrl/examples/synthetic-trace.json +46 -0
- ithrl/execution/__init__.py +68 -0
- ithrl/execution/budget.py +146 -0
- ithrl/execution/contracts.py +426 -0
- ithrl/execution/preflight.py +346 -0
- ithrl/experiment.py +453 -0
- ithrl/extraction.py +206 -0
- ithrl/measurement.py +315 -0
- ithrl/observations.py +1148 -0
- ithrl/path_safety.py +100 -0
- ithrl/reduce/__init__.py +53 -0
- ithrl/reduce/contracts.py +508 -0
- ithrl/reduce/engine.py +901 -0
- ithrl/reduce/search.py +178 -0
- ithrl/results/__init__.py +49 -0
- ithrl/results/models.py +294 -0
- ithrl/results/storage.py +485 -0
- ithrl/results/summary.py +420 -0
- ithrl/results/validation.py +611 -0
- ithrl/run_state.py +1135 -0
- ithrl/runner/__init__.py +44 -0
- ithrl/runner/codex/__init__.py +17 -0
- ithrl/runner/codex/capability.py +708 -0
- ithrl/runner/codex/configuration.py +529 -0
- ithrl/runner/codex/isolation.py +396 -0
- ithrl/runner/codex/materialize.py +358 -0
- ithrl/runner/codex/policy.py +67 -0
- ithrl/runner/codex/protocol.py +649 -0
- ithrl/runner/codex/protocol_contracts.py +171 -0
- ithrl/runner/codex/runner.py +533 -0
- ithrl/runner/codex/runtime_configuration.py +264 -0
- ithrl/runner/codex/schema.py +527 -0
- ithrl/runner/codex/transcript.py +130 -0
- ithrl/runner/codex/worker.py +175 -0
- ithrl/runner/contracts.py +419 -0
- ithrl/runner/copilot/__init__.py +18 -0
- ithrl/runner/copilot/capability.py +825 -0
- ithrl/runner/copilot/configuration.py +201 -0
- ithrl/runner/copilot/isolation.py +374 -0
- ithrl/runner/copilot/materialize.py +484 -0
- ithrl/runner/copilot/policy.py +110 -0
- ithrl/runner/copilot/protocol/__init__.py +18 -0
- ithrl/runner/copilot/protocol/contracts.py +74 -0
- ithrl/runner/copilot/protocol/decoding.py +93 -0
- ithrl/runner/copilot/protocol/parser.py +418 -0
- ithrl/runner/copilot/protocol/state.py +130 -0
- ithrl/runner/copilot/runner.py +425 -0
- ithrl/runner/copilot/worker.py +351 -0
- ithrl/runner/evidence.py +345 -0
- ithrl/runner/fake.py +107 -0
- ithrl/runner/registry.py +90 -0
- ithrl/runner/runtime/__init__.py +50 -0
- ithrl/runner/runtime/contracts.py +157 -0
- ithrl/runner/runtime/output.py +184 -0
- ithrl/runner/runtime/process.py +954 -0
- ithrl/runner/runtime/protection.py +562 -0
- ithrl/runner/runtime/state.py +282 -0
- ithrl/runner/validation.py +376 -0
- ithrl/sampling.py +412 -0
- ithrl/statistics.py +276 -0
- ithrl/synthetic.py +511 -0
- ithrl/timestamps.py +26 -0
- ithrl/workflow.py +570 -0
- ithrl-0.1.0.dist-info/METADATA +118 -0
- ithrl-0.1.0.dist-info/RECORD +108 -0
- ithrl-0.1.0.dist-info/WHEEL +4 -0
- ithrl-0.1.0.dist-info/entry_points.txt +2 -0
- ithrl-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Validate the supported GitHub Copilot CLI session format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Never, cast
|
|
8
|
+
|
|
9
|
+
from ithrl.adapters._jsonl import JsonlDocument
|
|
10
|
+
from ithrl.adapters.contracts import NativeSession, SessionFormat, SessionMetadata
|
|
11
|
+
from ithrl.adapters.errors import AdapterError, AdapterErrorCode
|
|
12
|
+
from ithrl.context import JsonObject, JsonValue
|
|
13
|
+
from ithrl.timestamps import validate_rfc3339_timestamp
|
|
14
|
+
|
|
15
|
+
_FORMAT_NAME = "copilot-events-jsonl"
|
|
16
|
+
_PRODUCER = "copilot-agent"
|
|
17
|
+
_SUPPORTED_RECORD_SCHEMA = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class _Record:
|
|
22
|
+
line: int
|
|
23
|
+
record_id: str
|
|
24
|
+
parent_id: str | None
|
|
25
|
+
record_type: str
|
|
26
|
+
data: JsonObject
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class _ToolCall:
|
|
31
|
+
turn_id: str
|
|
32
|
+
started: bool = False
|
|
33
|
+
completed: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class _RelationshipState:
|
|
38
|
+
open_turns: dict[str, int] = field(default_factory=dict)
|
|
39
|
+
tools: dict[str, _ToolCall] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _CopilotParser:
|
|
43
|
+
format_name = _FORMAT_NAME
|
|
44
|
+
|
|
45
|
+
def matches(self, document: JsonlDocument) -> bool:
|
|
46
|
+
"""Return whether the first record has the Copilot session signature."""
|
|
47
|
+
|
|
48
|
+
if not document.records:
|
|
49
|
+
return False
|
|
50
|
+
first = document.records[0]
|
|
51
|
+
data = first.get("data")
|
|
52
|
+
return (
|
|
53
|
+
first.get("type") == "session.start"
|
|
54
|
+
and isinstance(data, dict)
|
|
55
|
+
and data.get("producer") == _PRODUCER
|
|
56
|
+
and "sessionId" in data
|
|
57
|
+
and "version" in data
|
|
58
|
+
and "copilotVersion" in data
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def parse(self, document: JsonlDocument) -> NativeSession:
|
|
62
|
+
"""Validate and preserve one bounded Copilot session document."""
|
|
63
|
+
|
|
64
|
+
if not self.matches(document):
|
|
65
|
+
raise AdapterError(
|
|
66
|
+
AdapterErrorCode.UNSUPPORTED_FORMAT,
|
|
67
|
+
"session source is not a GitHub Copilot CLI event log",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
records = _validate_envelopes(document.records)
|
|
71
|
+
metadata = _parse_metadata(records)
|
|
72
|
+
_validate_turns_and_tools(records)
|
|
73
|
+
return NativeSession(
|
|
74
|
+
metadata=metadata,
|
|
75
|
+
source=document.source,
|
|
76
|
+
native_bytes=document.raw,
|
|
77
|
+
records=document.records,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
PARSER = _CopilotParser()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _validate_envelopes(records: tuple[JsonObject, ...]) -> tuple[_Record, ...]:
|
|
85
|
+
validated: list[_Record] = []
|
|
86
|
+
record_ids: set[str] = set()
|
|
87
|
+
for line, record in enumerate(records, start=1):
|
|
88
|
+
record_id = _required_string(record.get("id"), line, "session record ID is missing")
|
|
89
|
+
if record_id in record_ids:
|
|
90
|
+
_invalid("session record IDs must be unique", line)
|
|
91
|
+
record_ids.add(record_id)
|
|
92
|
+
|
|
93
|
+
parent = record.get("parentId")
|
|
94
|
+
if parent is not None and (not isinstance(parent, str) or not parent):
|
|
95
|
+
_invalid("session record parent ID must be a non-empty string or null", line)
|
|
96
|
+
|
|
97
|
+
timestamp = _required_string(
|
|
98
|
+
record.get("timestamp"), line, "session record timestamp is missing"
|
|
99
|
+
)
|
|
100
|
+
try:
|
|
101
|
+
validate_rfc3339_timestamp(timestamp, field="session record timestamp")
|
|
102
|
+
except ValueError:
|
|
103
|
+
raise AdapterError(
|
|
104
|
+
AdapterErrorCode.INVALID_SESSION,
|
|
105
|
+
"session record timestamp is not valid RFC 3339",
|
|
106
|
+
line=line,
|
|
107
|
+
) from None
|
|
108
|
+
record_type = _required_string(record.get("type"), line, "session record type is missing")
|
|
109
|
+
data = record.get("data")
|
|
110
|
+
if not isinstance(data, dict):
|
|
111
|
+
_invalid("session record data must be an object", line)
|
|
112
|
+
|
|
113
|
+
validated.append(
|
|
114
|
+
_Record(
|
|
115
|
+
line=line,
|
|
116
|
+
record_id=record_id,
|
|
117
|
+
parent_id=parent,
|
|
118
|
+
record_type=record_type,
|
|
119
|
+
data=cast(JsonObject, data),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return tuple(validated)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_metadata(records: tuple[_Record, ...]) -> SessionMetadata:
|
|
126
|
+
starts = [record for record in records if record.record_type == "session.start"]
|
|
127
|
+
if len(starts) != 1 or starts[0].line != 1:
|
|
128
|
+
_invalid("session must begin with exactly one session.start record", 1)
|
|
129
|
+
start = starts[0]
|
|
130
|
+
|
|
131
|
+
session_id = _required_string(start.data.get("sessionId"), start.line, "session ID is missing")
|
|
132
|
+
record_schema = start.data.get("version")
|
|
133
|
+
if type(record_schema) is not int or record_schema != _SUPPORTED_RECORD_SCHEMA:
|
|
134
|
+
raise AdapterError(
|
|
135
|
+
AdapterErrorCode.UNSUPPORTED_VERSION,
|
|
136
|
+
"GitHub Copilot CLI record schema is not supported",
|
|
137
|
+
line=start.line,
|
|
138
|
+
)
|
|
139
|
+
producer_version = _required_string(
|
|
140
|
+
start.data.get("copilotVersion"), start.line, "producer version is missing"
|
|
141
|
+
)
|
|
142
|
+
captured_at = _required_string(
|
|
143
|
+
start.data.get("startTime"), start.line, "session start time is missing"
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
validate_rfc3339_timestamp(captured_at, field="session start time")
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise AdapterError(
|
|
149
|
+
AdapterErrorCode.INVALID_SESSION,
|
|
150
|
+
"session start time is not valid RFC 3339",
|
|
151
|
+
line=start.line,
|
|
152
|
+
) from None
|
|
153
|
+
|
|
154
|
+
return SessionMetadata(
|
|
155
|
+
format=SessionFormat(
|
|
156
|
+
harness="copilot",
|
|
157
|
+
name=_FORMAT_NAME,
|
|
158
|
+
producer_version=producer_version,
|
|
159
|
+
record_schema_version=record_schema,
|
|
160
|
+
),
|
|
161
|
+
session_id=session_id,
|
|
162
|
+
captured_at=captured_at,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _validate_turns_and_tools(records: tuple[_Record, ...]) -> None:
|
|
167
|
+
state = _RelationshipState()
|
|
168
|
+
for record in records:
|
|
169
|
+
if validator := _RELATIONSHIP_VALIDATORS.get(record.record_type):
|
|
170
|
+
validator(record, state)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _start_turn(record: _Record, state: _RelationshipState) -> None:
|
|
174
|
+
turn_id = _turn_id(record)
|
|
175
|
+
state.open_turns[turn_id] = state.open_turns.get(turn_id, 0) + 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _end_turn(record: _Record, state: _RelationshipState) -> None:
|
|
179
|
+
turn_id = _turn_id(record)
|
|
180
|
+
depth = state.open_turns.get(turn_id, 0)
|
|
181
|
+
if depth == 0:
|
|
182
|
+
_invalid("assistant turn ends before it starts", record.line)
|
|
183
|
+
if depth == 1:
|
|
184
|
+
del state.open_turns[turn_id]
|
|
185
|
+
else:
|
|
186
|
+
state.open_turns[turn_id] = depth - 1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _add_tool_requests(
|
|
190
|
+
record: _Record,
|
|
191
|
+
state: _RelationshipState,
|
|
192
|
+
) -> None:
|
|
193
|
+
requests = record.data.get("toolRequests")
|
|
194
|
+
if requests is None:
|
|
195
|
+
return
|
|
196
|
+
if not isinstance(requests, list):
|
|
197
|
+
_invalid("assistant tool requests must be an array", record.line)
|
|
198
|
+
if not requests:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
turn_id = _turn_id(record)
|
|
202
|
+
_require_open_turn(turn_id, record.line, state.open_turns)
|
|
203
|
+
for request in requests:
|
|
204
|
+
if not isinstance(request, dict):
|
|
205
|
+
_invalid("assistant tool request must be an object", record.line)
|
|
206
|
+
tool_call_id = _required_string(
|
|
207
|
+
request.get("toolCallId"), record.line, "assistant tool call ID is missing"
|
|
208
|
+
)
|
|
209
|
+
if tool_call_id in state.tools:
|
|
210
|
+
_invalid("assistant tool call IDs must be unique", record.line)
|
|
211
|
+
state.tools[tool_call_id] = _ToolCall(turn_id=turn_id)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _start_tool(
|
|
215
|
+
record: _Record,
|
|
216
|
+
state: _RelationshipState,
|
|
217
|
+
) -> None:
|
|
218
|
+
tool_call_id = _required_string(
|
|
219
|
+
record.data.get("toolCallId"), record.line, "tool execution call ID is missing"
|
|
220
|
+
)
|
|
221
|
+
turn_id = _turn_id(record)
|
|
222
|
+
tool = state.tools.get(tool_call_id)
|
|
223
|
+
if tool is None:
|
|
224
|
+
_invalid("tool execution starts before its assistant request", record.line)
|
|
225
|
+
_require_open_turn(turn_id, record.line, state.open_turns)
|
|
226
|
+
if tool.turn_id != turn_id:
|
|
227
|
+
_invalid("tool execution and assistant request use different turns", record.line)
|
|
228
|
+
if tool.started:
|
|
229
|
+
_invalid("tool execution starts more than once", record.line)
|
|
230
|
+
tool.started = True
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _complete_tool(
|
|
234
|
+
record: _Record,
|
|
235
|
+
state: _RelationshipState,
|
|
236
|
+
) -> None:
|
|
237
|
+
tool_call_id = _required_string(
|
|
238
|
+
record.data.get("toolCallId"), record.line, "tool completion call ID is missing"
|
|
239
|
+
)
|
|
240
|
+
turn_id = _turn_id(record)
|
|
241
|
+
tool = state.tools.get(tool_call_id)
|
|
242
|
+
if tool is None or not tool.started:
|
|
243
|
+
_invalid("tool execution completes before it starts", record.line)
|
|
244
|
+
_require_open_turn(turn_id, record.line, state.open_turns)
|
|
245
|
+
if tool.turn_id != turn_id:
|
|
246
|
+
_invalid("tool completion and assistant request use different turns", record.line)
|
|
247
|
+
if tool.completed:
|
|
248
|
+
_invalid("tool execution completes more than once", record.line)
|
|
249
|
+
tool.completed = True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _require_open_turn(
|
|
253
|
+
turn_id: str,
|
|
254
|
+
line: int,
|
|
255
|
+
open_turns: dict[str, int],
|
|
256
|
+
) -> None:
|
|
257
|
+
if not open_turns.get(turn_id, 0):
|
|
258
|
+
_invalid("tool relationship is outside its assistant turn", line)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _turn_id(record: _Record) -> str:
|
|
262
|
+
return _required_string(record.data.get("turnId"), record.line, "turn ID is missing")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _required_string(value: JsonValue | None, line: int, message: str) -> str:
|
|
266
|
+
if not isinstance(value, str) or not value:
|
|
267
|
+
_invalid(message, line)
|
|
268
|
+
return value
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _invalid(message: str, line: int) -> Never:
|
|
272
|
+
raise AdapterError(AdapterErrorCode.INVALID_SESSION, message, line=line)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
type _RelationshipValidator = Callable[[_Record, _RelationshipState], None]
|
|
276
|
+
|
|
277
|
+
_RELATIONSHIP_VALIDATORS: dict[str, _RelationshipValidator] = {
|
|
278
|
+
"assistant.turn_start": _start_turn,
|
|
279
|
+
"assistant.message": _add_tool_requests,
|
|
280
|
+
"tool.execution_start": _start_tool,
|
|
281
|
+
"tool.execution_complete": _complete_tool,
|
|
282
|
+
"assistant.turn_end": _end_turn,
|
|
283
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Bounded contracts and filesystem traversal for native session discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
from ithrl.adapters.contracts import SourceFacts
|
|
15
|
+
from ithrl.adapters.errors import AdapterError, AdapterErrorCode
|
|
16
|
+
from ithrl.behavior import BehaviorType
|
|
17
|
+
from ithrl.decisions import DecisionLimitation
|
|
18
|
+
from ithrl.path_safety import UnsafePathError, validate_directory_chain
|
|
19
|
+
from ithrl.timestamps import validate_rfc3339_timestamp
|
|
20
|
+
|
|
21
|
+
type HarnessName = Literal["codex", "copilot"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class DiscoveryLimits:
|
|
26
|
+
"""Resource limits for one read-only default-store scan."""
|
|
27
|
+
|
|
28
|
+
max_entries: int = 50_000
|
|
29
|
+
max_candidates: int = 4_096
|
|
30
|
+
max_parse_bytes: int = 512 * 1024 * 1024
|
|
31
|
+
max_sessions: int = 10
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if (
|
|
35
|
+
min(
|
|
36
|
+
self.max_entries,
|
|
37
|
+
self.max_candidates,
|
|
38
|
+
self.max_parse_bytes,
|
|
39
|
+
self.max_sessions,
|
|
40
|
+
)
|
|
41
|
+
< 1
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("discovery limits must be positive")
|
|
44
|
+
if self.max_candidates > self.max_entries:
|
|
45
|
+
raise ValueError("max_candidates must not exceed max_entries")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class SessionSource:
|
|
50
|
+
"""One candidate native session file found under a known store."""
|
|
51
|
+
|
|
52
|
+
path: Path
|
|
53
|
+
modified_at_ns: int
|
|
54
|
+
size_bytes: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class SourceScan:
|
|
59
|
+
"""A bounded set of matching source files from one native store."""
|
|
60
|
+
|
|
61
|
+
sources: tuple[SessionSource, ...]
|
|
62
|
+
truncated: bool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SessionSummary(BaseModel):
|
|
66
|
+
"""Native session facts that can be shown without captured content."""
|
|
67
|
+
|
|
68
|
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
|
69
|
+
|
|
70
|
+
schema_version: Literal[1] = 1
|
|
71
|
+
harness: HarnessName
|
|
72
|
+
session_id: Annotated[str, Field(min_length=1, max_length=1024)]
|
|
73
|
+
producer_version: Annotated[str, Field(min_length=1, max_length=128)]
|
|
74
|
+
captured_at: Annotated[str, Field(min_length=1, max_length=64)]
|
|
75
|
+
decision_count: Annotated[int, Field(ge=1, le=4096)]
|
|
76
|
+
target_types: Annotated[tuple[BehaviorType, ...], Field(min_length=1, max_length=2)]
|
|
77
|
+
limitations: tuple[DecisionLimitation, ...] = ()
|
|
78
|
+
|
|
79
|
+
@field_validator("captured_at")
|
|
80
|
+
@classmethod
|
|
81
|
+
def validate_captured_at(cls, value: str) -> str:
|
|
82
|
+
return validate_rfc3339_timestamp(value, field="captured_at")
|
|
83
|
+
|
|
84
|
+
@model_validator(mode="after")
|
|
85
|
+
def validate_unique_values(self) -> SessionSummary:
|
|
86
|
+
if len(set(self.target_types)) != len(self.target_types):
|
|
87
|
+
raise ValueError("target_types must not contain duplicates")
|
|
88
|
+
if len(set(self.limitations)) != len(self.limitations):
|
|
89
|
+
raise ValueError("limitations must not contain duplicates")
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class DiscoveredSession:
|
|
95
|
+
"""A display-safe summary paired with its internal source path."""
|
|
96
|
+
|
|
97
|
+
source: SessionSource
|
|
98
|
+
summary: SessionSummary
|
|
99
|
+
source_facts: SourceFacts
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class DiscoveryResult:
|
|
104
|
+
"""Bounded supported sessions and scan counts that expose no session content."""
|
|
105
|
+
|
|
106
|
+
sessions: tuple[DiscoveredSession, ...]
|
|
107
|
+
candidates_seen: int
|
|
108
|
+
rejected_candidates: int
|
|
109
|
+
rejection_codes: tuple[AdapterErrorCode, ...]
|
|
110
|
+
truncated: bool
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scan_store(
|
|
114
|
+
root: Path,
|
|
115
|
+
*,
|
|
116
|
+
max_depth: int,
|
|
117
|
+
accepts: Callable[[Path], bool],
|
|
118
|
+
limits: DiscoveryLimits,
|
|
119
|
+
) -> SourceScan:
|
|
120
|
+
"""Return matching regular files without following directory links."""
|
|
121
|
+
|
|
122
|
+
if max_depth < 0:
|
|
123
|
+
raise ValueError("max_depth must not be negative")
|
|
124
|
+
if not _store_is_available(root):
|
|
125
|
+
return SourceScan(sources=(), truncated=False)
|
|
126
|
+
|
|
127
|
+
candidates: list[SessionSource] = []
|
|
128
|
+
matching_entries = 0
|
|
129
|
+
for path, entry_metadata in _walk_store(
|
|
130
|
+
root,
|
|
131
|
+
max_depth=max_depth,
|
|
132
|
+
max_entries=limits.max_entries,
|
|
133
|
+
):
|
|
134
|
+
if not accepts(path.relative_to(root)):
|
|
135
|
+
continue
|
|
136
|
+
matching_entries += 1
|
|
137
|
+
if stat.S_ISLNK(entry_metadata.st_mode) or not stat.S_ISREG(entry_metadata.st_mode):
|
|
138
|
+
raise AdapterError(
|
|
139
|
+
AdapterErrorCode.UNSAFE_SOURCE,
|
|
140
|
+
"native session candidate must be a regular file, not a link",
|
|
141
|
+
)
|
|
142
|
+
candidates.append(
|
|
143
|
+
SessionSource(
|
|
144
|
+
path=path,
|
|
145
|
+
modified_at_ns=entry_metadata.st_mtime_ns,
|
|
146
|
+
size_bytes=entry_metadata.st_size,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
if len(candidates) == limits.max_candidates * 2:
|
|
150
|
+
candidates.sort(key=_source_sort_key)
|
|
151
|
+
del candidates[limits.max_candidates :]
|
|
152
|
+
candidates.sort(key=_source_sort_key)
|
|
153
|
+
del candidates[limits.max_candidates :]
|
|
154
|
+
return SourceScan(
|
|
155
|
+
sources=tuple(candidates),
|
|
156
|
+
truncated=matching_entries > len(candidates),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _store_is_available(root: Path) -> bool:
|
|
161
|
+
try:
|
|
162
|
+
metadata = root.lstat()
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
return False
|
|
165
|
+
except OSError:
|
|
166
|
+
raise AdapterError(
|
|
167
|
+
AdapterErrorCode.SOURCE_UNAVAILABLE,
|
|
168
|
+
"native session store is unavailable",
|
|
169
|
+
) from None
|
|
170
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
|
171
|
+
raise AdapterError(
|
|
172
|
+
AdapterErrorCode.UNSAFE_SOURCE,
|
|
173
|
+
"native session store must be a directory, not a link",
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
validate_directory_chain(root)
|
|
177
|
+
except UnsafePathError:
|
|
178
|
+
raise AdapterError(
|
|
179
|
+
AdapterErrorCode.UNSAFE_SOURCE,
|
|
180
|
+
"native session store has an unsafe controlling directory",
|
|
181
|
+
) from None
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _walk_store(
|
|
186
|
+
root: Path,
|
|
187
|
+
*,
|
|
188
|
+
max_depth: int,
|
|
189
|
+
max_entries: int,
|
|
190
|
+
) -> Iterator[tuple[Path, os.stat_result]]:
|
|
191
|
+
pending = [(root, 0)]
|
|
192
|
+
entries_seen = 0
|
|
193
|
+
while pending:
|
|
194
|
+
directory, depth = pending.pop()
|
|
195
|
+
try:
|
|
196
|
+
with os.scandir(directory) as entries:
|
|
197
|
+
for entry in entries:
|
|
198
|
+
entries_seen += 1
|
|
199
|
+
if entries_seen > max_entries:
|
|
200
|
+
raise AdapterError(
|
|
201
|
+
AdapterErrorCode.DISCOVERY_LIMIT_EXCEEDED,
|
|
202
|
+
"native session store exceeds the discovery entry limit",
|
|
203
|
+
)
|
|
204
|
+
entry_metadata = entry.stat(follow_symlinks=False)
|
|
205
|
+
if stat.S_ISDIR(entry_metadata.st_mode):
|
|
206
|
+
if depth < max_depth and not entry.is_symlink():
|
|
207
|
+
pending.append((Path(entry.path), depth + 1))
|
|
208
|
+
continue
|
|
209
|
+
yield Path(entry.path), entry_metadata
|
|
210
|
+
except AdapterError:
|
|
211
|
+
raise
|
|
212
|
+
except OSError:
|
|
213
|
+
raise AdapterError(
|
|
214
|
+
AdapterErrorCode.SOURCE_UNAVAILABLE,
|
|
215
|
+
"native session store cannot be inspected",
|
|
216
|
+
) from None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _source_sort_key(source: SessionSource) -> tuple[int, str]:
|
|
220
|
+
return -source.modified_at_ns, source.path.as_posix()
|
ithrl/adapters/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Stable native-session errors that do not expose captured content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AdapterErrorCode(StrEnum):
|
|
9
|
+
"""Machine-readable reasons native session evidence cannot be used."""
|
|
10
|
+
|
|
11
|
+
SOURCE_UNAVAILABLE = "source-unavailable"
|
|
12
|
+
UNSAFE_SOURCE = "unsafe-source"
|
|
13
|
+
SOURCE_TOO_LARGE = "source-too-large"
|
|
14
|
+
LINE_TOO_LARGE = "line-too-large"
|
|
15
|
+
TOO_MANY_RECORDS = "too-many-records"
|
|
16
|
+
INVALID_UTF8 = "invalid-utf8"
|
|
17
|
+
INVALID_JSONL = "invalid-jsonl"
|
|
18
|
+
DUPLICATE_KEY = "duplicate-key"
|
|
19
|
+
JSON_LIMIT_EXCEEDED = "json-limit-exceeded"
|
|
20
|
+
SOURCE_CHANGED = "source-changed"
|
|
21
|
+
UNSUPPORTED_FORMAT = "unsupported-format"
|
|
22
|
+
AMBIGUOUS_FORMAT = "ambiguous-format"
|
|
23
|
+
UNSUPPORTED_VERSION = "unsupported-version"
|
|
24
|
+
INVALID_SESSION = "invalid-session"
|
|
25
|
+
CONTEXT_TOO_LARGE = "context-too-large"
|
|
26
|
+
DECISION_NOT_FOUND = "decision-not-found"
|
|
27
|
+
TARGET_NOT_FOUND = "target-not-found"
|
|
28
|
+
TOO_MANY_DECISIONS = "too-many-decisions"
|
|
29
|
+
DISCOVERY_LIMIT_EXCEEDED = "discovery-limit-exceeded"
|
|
30
|
+
SESSION_NOT_FOUND = "session-not-found"
|
|
31
|
+
AMBIGUOUS_SESSION = "ambiguous-session"
|
|
32
|
+
NO_SESSIONS = "no-sessions"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AdapterError(ValueError):
|
|
36
|
+
"""Native session evidence cannot be read, validated, or selected safely."""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("bytes_read", "code", "line")
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
code: AdapterErrorCode,
|
|
43
|
+
message: str,
|
|
44
|
+
*,
|
|
45
|
+
line: int | None = None,
|
|
46
|
+
bytes_read: int = 0,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.code = code
|
|
49
|
+
self.line = line
|
|
50
|
+
self.bytes_read = bytes_read
|
|
51
|
+
location = f" at line {line}" if line is not None else ""
|
|
52
|
+
super().__init__(f"{message}{location}")
|