stepfork 0.1.0a2__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.
- stepfork/__init__.py +32 -0
- stepfork/__main__.py +6 -0
- stepfork/cli/__init__.py +1 -0
- stepfork/cli/diff.py +156 -0
- stepfork/cli/export.py +210 -0
- stepfork/cli/inspect.py +155 -0
- stepfork/cli/main.py +54 -0
- stepfork/cli/replay.py +221 -0
- stepfork/cli/validate.py +160 -0
- stepfork/diff/__init__.py +25 -0
- stepfork/diff/compare.py +144 -0
- stepfork/diff/engine.py +267 -0
- stepfork/diff/models.py +63 -0
- stepfork/export/__init__.py +20 -0
- stepfork/export/entrypoint.py +58 -0
- stepfork/export/generator.py +178 -0
- stepfork/export/runtime.py +101 -0
- stepfork/fork/__init__.py +4 -0
- stepfork/inspect/__init__.py +11 -0
- stepfork/inspect/inspector.py +143 -0
- stepfork/inspect/models.py +42 -0
- stepfork/minimize/__init__.py +4 -0
- stepfork/py.typed +0 -0
- stepfork/recorder/__init__.py +21 -0
- stepfork/recorder/llm.py +68 -0
- stepfork/recorder/session.py +500 -0
- stepfork/recorder/tooling.py +208 -0
- stepfork/replay/__init__.py +32 -0
- stepfork/replay/exceptions.py +37 -0
- stepfork/replay/plan.py +282 -0
- stepfork/replay/session.py +338 -0
- stepfork/trace/__init__.py +115 -0
- stepfork/trace/canonical.py +55 -0
- stepfork/trace/hashing.py +75 -0
- stepfork/trace/integrity.py +196 -0
- stepfork/trace/jsonable.py +97 -0
- stepfork/trace/manifest.py +85 -0
- stepfork/trace/models.py +271 -0
- stepfork/trace/redaction.py +294 -0
- stepfork/trace/replay_policy.py +18 -0
- stepfork/trace/schema.py +25 -0
- stepfork/trace/storage.py +250 -0
- stepfork/trace/validation.py +285 -0
- stepfork/version.py +5 -0
- stepfork-0.1.0a2.dist-info/METADATA +377 -0
- stepfork-0.1.0a2.dist-info/RECORD +49 -0
- stepfork-0.1.0a2.dist-info/WHEEL +4 -0
- stepfork-0.1.0a2.dist-info/entry_points.txt +2 -0
- stepfork-0.1.0a2.dist-info/licenses/LICENSE +184 -0
stepfork/trace/models.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Typed event models for the Stepfork `.sftrace` v0.1 contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, TypeVar, cast
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
12
|
+
from typing_extensions import TypeAliasType
|
|
13
|
+
|
|
14
|
+
from stepfork.trace.manifest import (
|
|
15
|
+
EnvironmentInfo,
|
|
16
|
+
FailureInfo,
|
|
17
|
+
RunStatus,
|
|
18
|
+
TraceTotals,
|
|
19
|
+
)
|
|
20
|
+
from stepfork.trace.replay_policy import ReplayPolicy
|
|
21
|
+
|
|
22
|
+
JsonPrimitive: TypeAlias = str | int | float | bool | None
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
JsonValue: TypeAlias = JsonPrimitive | list[Any] | dict[str, Any]
|
|
25
|
+
else:
|
|
26
|
+
JsonValue = TypeAliasType(
|
|
27
|
+
"JsonValue",
|
|
28
|
+
JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"],
|
|
29
|
+
)
|
|
30
|
+
E = TypeVar("E", bound="EventBase")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _new_event_id() -> str:
|
|
34
|
+
"""Return an opaque local event ID."""
|
|
35
|
+
return f"evt_{uuid4().hex}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _new_run_id() -> str:
|
|
39
|
+
"""Return an opaque local run ID."""
|
|
40
|
+
return f"run_{uuid4().hex}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _utc_now() -> datetime:
|
|
44
|
+
"""Return the current timezone-aware UTC timestamp."""
|
|
45
|
+
return datetime.now(UTC)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EventStatus(StrEnum):
|
|
49
|
+
"""Status for a recorded event."""
|
|
50
|
+
|
|
51
|
+
OK = "ok"
|
|
52
|
+
ERROR = "error"
|
|
53
|
+
SKIPPED = "skipped"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EventType(StrEnum):
|
|
57
|
+
"""The deliberately small v0.1 event taxonomy."""
|
|
58
|
+
|
|
59
|
+
RUN_START = "run_start"
|
|
60
|
+
LLM_REQUEST = "llm_request"
|
|
61
|
+
LLM_RESPONSE = "llm_response"
|
|
62
|
+
TOOL_CALL = "tool_call"
|
|
63
|
+
TOOL_RESULT = "tool_result"
|
|
64
|
+
STATE_CHANGE = "state_change"
|
|
65
|
+
ERROR = "error"
|
|
66
|
+
RUN_END = "run_end"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EventBase(BaseModel):
|
|
70
|
+
"""Common fields shared by all persisted trace events."""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(extra="forbid")
|
|
73
|
+
|
|
74
|
+
id: str = Field(default_factory=_new_event_id, min_length=1)
|
|
75
|
+
run_id: str = Field(default_factory=_new_run_id, min_length=1)
|
|
76
|
+
parent_id: str | None = Field(default=None, min_length=1)
|
|
77
|
+
|
|
78
|
+
step: int = Field(default=0, ge=0)
|
|
79
|
+
|
|
80
|
+
type: EventType
|
|
81
|
+
timestamp: datetime = Field(default_factory=_utc_now)
|
|
82
|
+
duration_ms: int | None = Field(default=None, ge=0)
|
|
83
|
+
|
|
84
|
+
status: EventStatus = EventStatus.OK
|
|
85
|
+
replay_policy: ReplayPolicy = ReplayPolicy.FROZEN
|
|
86
|
+
|
|
87
|
+
input_hash: str | None = None
|
|
88
|
+
output_hash: str | None = None
|
|
89
|
+
|
|
90
|
+
redactions: list[str] = Field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
@field_validator("timestamp")
|
|
93
|
+
@classmethod
|
|
94
|
+
def timestamp_must_be_timezone_aware(cls, value: datetime) -> datetime:
|
|
95
|
+
"""Reject naive datetimes instead of assuming UTC."""
|
|
96
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
97
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RunStart(EventBase):
|
|
102
|
+
"""Event marking the start of a run."""
|
|
103
|
+
|
|
104
|
+
type: Literal[EventType.RUN_START] = EventType.RUN_START
|
|
105
|
+
|
|
106
|
+
input: JsonValue | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class LLMRequest(EventBase):
|
|
110
|
+
"""Request sent to an LLM provider."""
|
|
111
|
+
|
|
112
|
+
type: Literal[EventType.LLM_REQUEST] = EventType.LLM_REQUEST
|
|
113
|
+
|
|
114
|
+
provider: str | None = Field(default=None, min_length=1)
|
|
115
|
+
model: str = Field(min_length=1)
|
|
116
|
+
input: JsonValue
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class LLMResponse(EventBase):
|
|
120
|
+
"""Response received from an LLM provider."""
|
|
121
|
+
|
|
122
|
+
type: Literal[EventType.LLM_RESPONSE] = EventType.LLM_RESPONSE
|
|
123
|
+
|
|
124
|
+
model: str | None = Field(default=None, min_length=1)
|
|
125
|
+
output: JsonValue
|
|
126
|
+
|
|
127
|
+
prompt_tokens: int | None = Field(default=None, ge=0)
|
|
128
|
+
completion_tokens: int | None = Field(default=None, ge=0)
|
|
129
|
+
total_tokens: int | None = Field(default=None, ge=0)
|
|
130
|
+
cost_usd: float | None = Field(default=None, ge=0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ToolCall(EventBase):
|
|
134
|
+
"""Call made to a tool.
|
|
135
|
+
|
|
136
|
+
Tool calls can be constructed directly with only `name` and JSON `input`.
|
|
137
|
+
Runtime defaults fill in event identity, run identity, 0-based step, and a
|
|
138
|
+
timezone-aware timestamp; `Trace.add()` normalizes run ID and step.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
type: Literal[EventType.TOOL_CALL] = EventType.TOOL_CALL
|
|
142
|
+
|
|
143
|
+
name: str = Field(min_length=1)
|
|
144
|
+
input: JsonValue
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ToolResult(EventBase):
|
|
148
|
+
"""Result returned by a tool."""
|
|
149
|
+
|
|
150
|
+
type: Literal[EventType.TOOL_RESULT] = EventType.TOOL_RESULT
|
|
151
|
+
|
|
152
|
+
name: str = Field(min_length=1)
|
|
153
|
+
output: JsonValue
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class StateChange(EventBase):
|
|
157
|
+
"""Change to agent state represented as JSON-compatible values."""
|
|
158
|
+
|
|
159
|
+
type: Literal[EventType.STATE_CHANGE] = EventType.STATE_CHANGE
|
|
160
|
+
|
|
161
|
+
key: str | None = Field(default=None, min_length=1)
|
|
162
|
+
before: JsonValue | None = None
|
|
163
|
+
after: JsonValue | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ErrorEvent(EventBase):
|
|
167
|
+
"""Error observed during a run."""
|
|
168
|
+
|
|
169
|
+
type: Literal[EventType.ERROR] = EventType.ERROR
|
|
170
|
+
|
|
171
|
+
status: EventStatus = EventStatus.ERROR
|
|
172
|
+
error_type: str = Field(min_length=1)
|
|
173
|
+
message: str = Field(min_length=1)
|
|
174
|
+
details: JsonValue | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class RunEnd(EventBase):
|
|
178
|
+
"""Event marking the end of a run."""
|
|
179
|
+
|
|
180
|
+
type: Literal[EventType.RUN_END] = EventType.RUN_END
|
|
181
|
+
|
|
182
|
+
run_status: RunStatus
|
|
183
|
+
output: JsonValue | None = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
Event: TypeAlias = Annotated[
|
|
187
|
+
RunStart
|
|
188
|
+
| LLMRequest
|
|
189
|
+
| LLMResponse
|
|
190
|
+
| ToolCall
|
|
191
|
+
| ToolResult
|
|
192
|
+
| StateChange
|
|
193
|
+
| ErrorEvent
|
|
194
|
+
| RunEnd,
|
|
195
|
+
Field(discriminator="type"),
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Trace(BaseModel):
|
|
200
|
+
"""Minimal in-memory trace container.
|
|
201
|
+
|
|
202
|
+
`Trace.add()` uses 0-based logical steps: the first added event receives
|
|
203
|
+
step `0`, the second receives step `1`, and so on. Partial traces can be
|
|
204
|
+
saved for development and later validated in partial or strict mode.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
model_config = ConfigDict(extra="forbid")
|
|
208
|
+
|
|
209
|
+
run_id: str = Field(default_factory=_new_run_id, min_length=1)
|
|
210
|
+
agent_name: str = Field(min_length=1)
|
|
211
|
+
created_at: datetime = Field(default_factory=_utc_now)
|
|
212
|
+
status: RunStatus = RunStatus.RUNNING
|
|
213
|
+
failure: FailureInfo | None = None
|
|
214
|
+
environment: EnvironmentInfo = Field(default_factory=EnvironmentInfo)
|
|
215
|
+
totals: TraceTotals = Field(default_factory=TraceTotals)
|
|
216
|
+
events: list[Event] = Field(default_factory=list)
|
|
217
|
+
|
|
218
|
+
@field_validator("created_at")
|
|
219
|
+
@classmethod
|
|
220
|
+
def created_at_must_be_timezone_aware(cls, value: datetime) -> datetime:
|
|
221
|
+
"""Reject naive datetimes instead of assuming UTC."""
|
|
222
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
223
|
+
raise ValueError("created_at must be timezone-aware")
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
def add(self, event: E) -> E:
|
|
227
|
+
"""Append an event after normalizing run ID and 0-based logical step."""
|
|
228
|
+
normalized = event.model_copy(
|
|
229
|
+
update={"run_id": self.run_id, "step": len(self.events)}
|
|
230
|
+
)
|
|
231
|
+
self.events.append(cast(Event, normalized))
|
|
232
|
+
self.totals = self._computed_totals()
|
|
233
|
+
return normalized
|
|
234
|
+
|
|
235
|
+
def _computed_totals(self) -> TraceTotals:
|
|
236
|
+
"""Return manifest counters derived from the current event sequence."""
|
|
237
|
+
return TraceTotals(
|
|
238
|
+
events=len(self.events),
|
|
239
|
+
llm_calls=sum(event.type is EventType.LLM_REQUEST for event in self.events),
|
|
240
|
+
tool_calls=sum(event.type is EventType.TOOL_CALL for event in self.events),
|
|
241
|
+
cost_usd=self.totals.cost_usd,
|
|
242
|
+
duration_ms=self.totals.duration_ms,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def save(
|
|
246
|
+
self,
|
|
247
|
+
path: str | Path,
|
|
248
|
+
*,
|
|
249
|
+
status: RunStatus | str | None = None,
|
|
250
|
+
failure: FailureInfo | None = None,
|
|
251
|
+
environment: EnvironmentInfo | None = None,
|
|
252
|
+
overwrite: bool = False,
|
|
253
|
+
) -> Path:
|
|
254
|
+
"""Persist this trace as a `.sftrace` directory bundle."""
|
|
255
|
+
from stepfork.trace.storage import save_trace
|
|
256
|
+
|
|
257
|
+
return save_trace(
|
|
258
|
+
self,
|
|
259
|
+
Path(path),
|
|
260
|
+
status=status,
|
|
261
|
+
failure=failure,
|
|
262
|
+
environment=environment,
|
|
263
|
+
overwrite=overwrite,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def load(cls, path: str | Path) -> Trace:
|
|
268
|
+
"""Load a trace from a `.sftrace` directory bundle."""
|
|
269
|
+
from stepfork.trace.storage import load_trace
|
|
270
|
+
|
|
271
|
+
return load_trace(Path(path))
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Best-effort secret redaction for trace persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
10
|
+
|
|
11
|
+
from stepfork.trace.manifest import TraceManifest
|
|
12
|
+
from stepfork.trace.models import Event, JsonValue, Trace
|
|
13
|
+
|
|
14
|
+
REDACTION_REPLACEMENT = "[REDACTED]"
|
|
15
|
+
MANIFEST_REDACTION_ID = "__manifest__"
|
|
16
|
+
|
|
17
|
+
SENSITIVE_KEY_NORMALIZED = {
|
|
18
|
+
"apikey",
|
|
19
|
+
"accesstoken",
|
|
20
|
+
"refreshtoken",
|
|
21
|
+
"authtoken",
|
|
22
|
+
"authorization",
|
|
23
|
+
"password",
|
|
24
|
+
"passwd",
|
|
25
|
+
"secret",
|
|
26
|
+
"clientsecret",
|
|
27
|
+
"privatekey",
|
|
28
|
+
"sessiontoken",
|
|
29
|
+
"bearertoken",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
PROTECTED_EVENT_FIELDS = {
|
|
33
|
+
"id",
|
|
34
|
+
"run_id",
|
|
35
|
+
"parent_id",
|
|
36
|
+
"step",
|
|
37
|
+
"type",
|
|
38
|
+
"timestamp",
|
|
39
|
+
"duration_ms",
|
|
40
|
+
"status",
|
|
41
|
+
"replay_policy",
|
|
42
|
+
"input_hash",
|
|
43
|
+
"output_hash",
|
|
44
|
+
"redactions",
|
|
45
|
+
"prompt_tokens",
|
|
46
|
+
"completion_tokens",
|
|
47
|
+
"total_tokens",
|
|
48
|
+
"cost_usd",
|
|
49
|
+
"run_status",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
PROTECTED_MANIFEST_FIELDS = {
|
|
53
|
+
"format",
|
|
54
|
+
"schema_version",
|
|
55
|
+
"run_id",
|
|
56
|
+
"created_at",
|
|
57
|
+
"status",
|
|
58
|
+
"totals",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_event_adapter: TypeAdapter[Event] = TypeAdapter(Event)
|
|
62
|
+
|
|
63
|
+
_CREDENTIAL_PATTERNS = [
|
|
64
|
+
re.compile(r"(?i)(Authorization:\s*Bearer\s+)([A-Za-z0-9._~+/=-]{8,})"),
|
|
65
|
+
re.compile(r"(?i)(Bearer\s+)([A-Za-z0-9._~+/=-]{8,})"),
|
|
66
|
+
re.compile(r"\b(sk-[A-Za-z0-9_-]{8,})\b"),
|
|
67
|
+
re.compile(r"\b(ghp_[A-Za-z0-9_]{8,})\b"),
|
|
68
|
+
re.compile(r"\b(gho_[A-Za-z0-9_]{8,})\b"),
|
|
69
|
+
re.compile(r"\b(github_pat_[A-Za-z0-9_]{8,})\b"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RedactionError(ValueError):
|
|
74
|
+
"""Raised when a value cannot be safely redacted for persistence."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RedactionEntry(BaseModel):
|
|
78
|
+
"""Persisted redaction metadata without raw secret values."""
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(extra="forbid")
|
|
81
|
+
|
|
82
|
+
event_id: str = Field(min_length=1)
|
|
83
|
+
path: str = Field(min_length=1)
|
|
84
|
+
reason: str = Field(min_length=1)
|
|
85
|
+
replacement: Literal["[REDACTED]"] = "[REDACTED]"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RedactionManifest(BaseModel):
|
|
89
|
+
"""Redactions recorded for a `.sftrace` bundle."""
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(extra="forbid")
|
|
92
|
+
|
|
93
|
+
schema_version: Literal["0.1"] = "0.1"
|
|
94
|
+
entries: list[RedactionEntry] = Field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class RedactionFinding:
|
|
99
|
+
"""Internal redaction finding before an event ID is attached."""
|
|
100
|
+
|
|
101
|
+
path: str
|
|
102
|
+
reason: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class RedactionResult:
|
|
107
|
+
"""Sanitized JSON value plus non-secret metadata."""
|
|
108
|
+
|
|
109
|
+
value: JsonValue
|
|
110
|
+
findings: list[RedactionFinding] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class PreparedTrace:
|
|
115
|
+
"""Trace data prepared for persistence."""
|
|
116
|
+
|
|
117
|
+
trace: Trace
|
|
118
|
+
manifest: TraceManifest
|
|
119
|
+
redactions: RedactionManifest
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def redact_json(value: JsonValue) -> RedactionResult:
|
|
123
|
+
"""Recursively redact sensitive keys and credential-like string spans."""
|
|
124
|
+
sanitized, findings = _redact_value(value, path="")
|
|
125
|
+
return RedactionResult(value=cast(JsonValue, sanitized), findings=findings)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def prepare_trace_for_persistence(
|
|
129
|
+
trace: Trace,
|
|
130
|
+
manifest: TraceManifest,
|
|
131
|
+
) -> PreparedTrace:
|
|
132
|
+
"""Return sanitized trace data and redaction metadata for writing."""
|
|
133
|
+
manifest_payload = manifest.model_dump(mode="json")
|
|
134
|
+
sanitized_manifest_payload, manifest_findings = _redact_mapping(
|
|
135
|
+
manifest_payload,
|
|
136
|
+
path="",
|
|
137
|
+
protected_fields=PROTECTED_MANIFEST_FIELDS,
|
|
138
|
+
)
|
|
139
|
+
sanitized_manifest = TraceManifest.model_validate(sanitized_manifest_payload)
|
|
140
|
+
|
|
141
|
+
sanitized_events: list[Event] = []
|
|
142
|
+
entries: list[RedactionEntry] = []
|
|
143
|
+
for event in trace.events:
|
|
144
|
+
event_payload = event.model_dump(mode="json")
|
|
145
|
+
sanitized_event_payload, findings = _redact_mapping(
|
|
146
|
+
event_payload,
|
|
147
|
+
path="",
|
|
148
|
+
protected_fields=PROTECTED_EVENT_FIELDS,
|
|
149
|
+
)
|
|
150
|
+
event_entries = [
|
|
151
|
+
RedactionEntry(
|
|
152
|
+
event_id=event.id,
|
|
153
|
+
path=finding.path,
|
|
154
|
+
reason=finding.reason,
|
|
155
|
+
)
|
|
156
|
+
for finding in findings
|
|
157
|
+
]
|
|
158
|
+
if event_entries:
|
|
159
|
+
existing = sanitized_event_payload.get("redactions", [])
|
|
160
|
+
sanitized_event_payload["redactions"] = [
|
|
161
|
+
*existing,
|
|
162
|
+
*(entry.path for entry in event_entries),
|
|
163
|
+
]
|
|
164
|
+
sanitized_events.append(_event_adapter.validate_python(sanitized_event_payload))
|
|
165
|
+
entries.extend(event_entries)
|
|
166
|
+
|
|
167
|
+
entries.extend(
|
|
168
|
+
RedactionEntry(
|
|
169
|
+
event_id=MANIFEST_REDACTION_ID,
|
|
170
|
+
path=finding.path,
|
|
171
|
+
reason=finding.reason,
|
|
172
|
+
)
|
|
173
|
+
for finding in manifest_findings
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
sanitized_trace = Trace(
|
|
177
|
+
run_id=sanitized_manifest.run_id,
|
|
178
|
+
agent_name=sanitized_manifest.agent_name,
|
|
179
|
+
created_at=sanitized_manifest.created_at,
|
|
180
|
+
status=sanitized_manifest.status,
|
|
181
|
+
failure=sanitized_manifest.failure,
|
|
182
|
+
environment=sanitized_manifest.environment,
|
|
183
|
+
totals=sanitized_manifest.totals,
|
|
184
|
+
events=sanitized_events,
|
|
185
|
+
)
|
|
186
|
+
return PreparedTrace(
|
|
187
|
+
trace=sanitized_trace,
|
|
188
|
+
manifest=sanitized_manifest,
|
|
189
|
+
redactions=RedactionManifest(entries=_dedupe_entries(entries)),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _redact_value(value: Any, *, path: str) -> tuple[Any, list[RedactionFinding]]:
|
|
194
|
+
if isinstance(value, dict):
|
|
195
|
+
return _redact_mapping(value, path=path, protected_fields=set())
|
|
196
|
+
if isinstance(value, list):
|
|
197
|
+
findings: list[RedactionFinding] = []
|
|
198
|
+
redacted_items: list[Any] = []
|
|
199
|
+
for index, item in enumerate(value):
|
|
200
|
+
redacted_item, item_findings = _redact_value(
|
|
201
|
+
item,
|
|
202
|
+
path=f"{path}/{index}",
|
|
203
|
+
)
|
|
204
|
+
redacted_items.append(redacted_item)
|
|
205
|
+
findings.extend(item_findings)
|
|
206
|
+
return redacted_items, findings
|
|
207
|
+
if isinstance(value, str):
|
|
208
|
+
return _redact_string(value, path=path)
|
|
209
|
+
return value, []
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _redact_mapping(
|
|
213
|
+
value: dict[str, Any],
|
|
214
|
+
*,
|
|
215
|
+
path: str,
|
|
216
|
+
protected_fields: set[str],
|
|
217
|
+
) -> tuple[dict[str, Any], list[RedactionFinding]]:
|
|
218
|
+
findings: list[RedactionFinding] = []
|
|
219
|
+
redacted: dict[str, Any] = {}
|
|
220
|
+
|
|
221
|
+
for key in sorted(value):
|
|
222
|
+
item = value[key]
|
|
223
|
+
item_path = f"{path}/{_escape_json_pointer(key)}"
|
|
224
|
+
if key in protected_fields:
|
|
225
|
+
_assert_protected_value_is_safe(item)
|
|
226
|
+
redacted[key] = item
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if _is_sensitive_key(key):
|
|
230
|
+
if item == REDACTION_REPLACEMENT:
|
|
231
|
+
redacted[key] = item
|
|
232
|
+
else:
|
|
233
|
+
redacted[key] = REDACTION_REPLACEMENT
|
|
234
|
+
findings.append(
|
|
235
|
+
RedactionFinding(path=item_path, reason="sensitive_key")
|
|
236
|
+
)
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
redacted_item, item_findings = _redact_value(item, path=item_path)
|
|
240
|
+
redacted[key] = redacted_item
|
|
241
|
+
findings.extend(item_findings)
|
|
242
|
+
|
|
243
|
+
return redacted, findings
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _redact_string(value: str, *, path: str) -> tuple[str, list[RedactionFinding]]:
|
|
247
|
+
if value == REDACTION_REPLACEMENT:
|
|
248
|
+
return value, []
|
|
249
|
+
|
|
250
|
+
redacted = value
|
|
251
|
+
changed = False
|
|
252
|
+
for pattern in _CREDENTIAL_PATTERNS:
|
|
253
|
+
redacted, replacements = pattern.subn(_replace_credential_match, redacted)
|
|
254
|
+
changed = changed or replacements > 0
|
|
255
|
+
|
|
256
|
+
if not changed:
|
|
257
|
+
return value, []
|
|
258
|
+
return redacted, [RedactionFinding(path=path, reason="credential_pattern")]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _replace_credential_match(match: re.Match[str]) -> str:
|
|
262
|
+
if len(match.groups()) == 2:
|
|
263
|
+
return f"{match.group(1)}{REDACTION_REPLACEMENT}"
|
|
264
|
+
return REDACTION_REPLACEMENT
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
268
|
+
return _normalize_key(key) in SENSITIVE_KEY_NORMALIZED
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _normalize_key(key: str) -> str:
|
|
272
|
+
camel_split = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key)
|
|
273
|
+
return re.sub(r"[^a-z0-9]", "", camel_split.lower())
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _escape_json_pointer(segment: str) -> str:
|
|
277
|
+
return segment.replace("~", "~0").replace("/", "~1")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _assert_protected_value_is_safe(value: Any) -> None:
|
|
281
|
+
if isinstance(value, str) and _redact_string(value, path="")[0] != value:
|
|
282
|
+
raise RedactionError("credential pattern found in protected structural field")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _dedupe_entries(entries: list[RedactionEntry]) -> list[RedactionEntry]:
|
|
286
|
+
seen: set[tuple[str, str, str]] = set()
|
|
287
|
+
unique: list[RedactionEntry] = []
|
|
288
|
+
for entry in entries:
|
|
289
|
+
key = (entry.event_id, entry.path, entry.reason)
|
|
290
|
+
if key in seen:
|
|
291
|
+
continue
|
|
292
|
+
seen.add(key)
|
|
293
|
+
unique.append(entry)
|
|
294
|
+
return unique
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Replay policy values for recorded trace dependencies."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReplayPolicy(StrEnum):
|
|
7
|
+
"""Describe how a dependency should behave during replay.
|
|
8
|
+
|
|
9
|
+
`frozen` returns captured output, `live` re-executes the dependency,
|
|
10
|
+
`manual` requires explicit user approval, `forbidden` prevents automatic
|
|
11
|
+
execution, and `derived` recomputes locally from deterministic inputs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
FROZEN = "frozen"
|
|
15
|
+
LIVE = "live"
|
|
16
|
+
MANUAL = "manual"
|
|
17
|
+
FORBIDDEN = "forbidden"
|
|
18
|
+
DERIVED = "derived"
|
stepfork/trace/schema.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""JSON Schema helpers for the Stepfork trace contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import TypeAdapter
|
|
8
|
+
|
|
9
|
+
from stepfork.trace.manifest import TraceManifest
|
|
10
|
+
from stepfork.trace.models import Event, Trace
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def event_json_schema() -> dict[str, Any]:
|
|
14
|
+
"""Return the generated JSON Schema for the discriminated Event union."""
|
|
15
|
+
return TypeAdapter(Event).json_schema()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def manifest_json_schema() -> dict[str, Any]:
|
|
19
|
+
"""Return the generated JSON Schema for trace manifests."""
|
|
20
|
+
return TraceManifest.model_json_schema()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def trace_json_schema() -> dict[str, Any]:
|
|
24
|
+
"""Return the generated JSON Schema for the in-memory Trace model."""
|
|
25
|
+
return Trace.model_json_schema()
|