agentmetry 0.4.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.
- agentmetry/__init__.py +12 -0
- agentmetry/api/__init__.py +0 -0
- agentmetry/api/main.py +232 -0
- agentmetry/api/routes/__init__.py +0 -0
- agentmetry/api/routes/audit.py +396 -0
- agentmetry/api/websocket.py +53 -0
- agentmetry/api/ws_bridge.py +34 -0
- agentmetry/cli/__init__.py +941 -0
- agentmetry/cli/__main__.py +5 -0
- agentmetry/core/__init__.py +0 -0
- agentmetry/core/audit/__init__.py +1 -0
- agentmetry/core/audit/adapters/__init__.py +0 -0
- agentmetry/core/audit/adapters/agt.py +304 -0
- agentmetry/core/audit/adapters/cloudevents.py +159 -0
- agentmetry/core/audit/adapters/ecs.py +103 -0
- agentmetry/core/audit/adapters/splunk.py +40 -0
- agentmetry/core/audit/alerts.py +56 -0
- agentmetry/core/audit/canonical.py +150 -0
- agentmetry/core/audit/compliance_digest.py +299 -0
- agentmetry/core/audit/detection/__init__.py +9 -0
- agentmetry/core/audit/detection/benchmark.py +194 -0
- agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
- agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
- agentmetry/core/audit/detection/disposition.py +651 -0
- agentmetry/core/audit/detection/engine.py +78 -0
- agentmetry/core/audit/detection/live.py +127 -0
- agentmetry/core/audit/detection/live_store.py +355 -0
- agentmetry/core/audit/detection/models.py +53 -0
- agentmetry/core/audit/detection/rules.py +1314 -0
- agentmetry/core/audit/detection/traits.py +648 -0
- agentmetry/core/audit/detection/yaml_config.py +91 -0
- agentmetry/core/audit/detection/yaml_rules.py +83 -0
- agentmetry/core/audit/dlp/__init__.py +4 -0
- agentmetry/core/audit/dlp/loader.py +29 -0
- agentmetry/core/audit/dlp/models.py +29 -0
- agentmetry/core/audit/dlp/scanner.py +96 -0
- agentmetry/core/audit/dogfood.py +398 -0
- agentmetry/core/audit/evidence_pack.py +500 -0
- agentmetry/core/audit/external.py +213 -0
- agentmetry/core/audit/hashing.py +21 -0
- agentmetry/core/audit/hook_bootstrap.py +451 -0
- agentmetry/core/audit/identity.py +39 -0
- agentmetry/core/audit/ingest.py +242 -0
- agentmetry/core/audit/migrate.py +73 -0
- agentmetry/core/audit/mitre.py +244 -0
- agentmetry/core/audit/policy.py +99 -0
- agentmetry/core/audit/redaction.py +50 -0
- agentmetry/core/audit/replay.py +54 -0
- agentmetry/core/audit/run_context.py +129 -0
- agentmetry/core/audit/sinks.py +235 -0
- agentmetry/core/audit/spool.py +394 -0
- agentmetry/core/audit/tool_policy/__init__.py +4 -0
- agentmetry/core/audit/tool_policy/evaluator.py +198 -0
- agentmetry/core/audit/tool_policy/loader.py +44 -0
- agentmetry/core/audit/tool_policy/models.py +25 -0
- agentmetry/core/audit/trail_chain.py +300 -0
- agentmetry/core/audit/trail_db.py +491 -0
- agentmetry/core/audit/trail_merkle.py +332 -0
- agentmetry/core/auth.py +54 -0
- agentmetry/core/bus/__init__.py +5 -0
- agentmetry/core/bus/audit_exporter.py +107 -0
- agentmetry/core/bus/bridges.py +26 -0
- agentmetry/core/bus/bus.py +102 -0
- agentmetry/core/bus/events.py +50 -0
- agentmetry/core/bus/outbox.py +124 -0
- agentmetry/core/config.py +177 -0
- agentmetry/core/diagnostics/__init__.py +0 -0
- agentmetry/core/diagnostics/autostart.py +563 -0
- agentmetry/core/diagnostics/doctor.py +535 -0
- agentmetry/core/diagnostics/driver_paths.py +156 -0
- agentmetry/core/diagnostics/env_file.py +45 -0
- agentmetry/core/drivers/__init__.py +4 -0
- agentmetry/core/drivers/host.py +263 -0
- agentmetry/core/drivers/permissions.py +37 -0
- agentmetry/core/drivers/spec.py +118 -0
- agentmetry/core/extensions.py +107 -0
- agentmetry/core/health.py +26 -0
- agentmetry/core/version.py +13 -0
- agentmetry/policies/detection/manifest.yaml +43 -0
- agentmetry/policies/dlp/manifest.yaml +161 -0
- agentmetry/policies/opa/agent_rules.rego +33 -0
- agentmetry/policies/tool/manifest.yaml +117 -0
- agentmetry-0.4.0.dist-info/METADATA +86 -0
- agentmetry-0.4.0.dist-info/RECORD +131 -0
- agentmetry-0.4.0.dist-info/WHEEL +4 -0
- agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Hash-chained JSONL audit trail records (tamper-evident file sink).
|
|
2
|
+
|
|
3
|
+
Each file line is an envelope:
|
|
4
|
+
|
|
5
|
+
{"trail": {"v": 1, "seq": N, "prev_sha256": "...", "record_sha256": "..."}, "event": {...}}
|
|
6
|
+
|
|
7
|
+
Legacy lines (plain canonical events) remain readable; verify reports them separately.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
TRAIL_VERSION = 1
|
|
22
|
+
GENESIS_SHA256 = hashlib.sha256(b"AGENTMETRY_TRAIL_GENESIS_v1").hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def chain_sidecar_path(trail_path: Path) -> Path:
|
|
26
|
+
return trail_path.with_name(trail_path.name + ".chain")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def canonical_event_json(event: dict[str, Any]) -> str:
|
|
30
|
+
return json.dumps(event, separators=(",", ":"), sort_keys=True, default=str)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def compute_record_sha256(prev_sha256: str, event: dict[str, Any]) -> str:
|
|
34
|
+
material = f"{prev_sha256}\n{canonical_event_json(event)}".encode("utf-8")
|
|
35
|
+
return hashlib.sha256(material).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def wrap_chained_record(seq: int, prev_sha256: str, event: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
record_sha256 = compute_record_sha256(prev_sha256, event)
|
|
40
|
+
return {
|
|
41
|
+
"trail": {
|
|
42
|
+
"v": TRAIL_VERSION,
|
|
43
|
+
"seq": seq,
|
|
44
|
+
"prev_sha256": prev_sha256,
|
|
45
|
+
"record_sha256": record_sha256,
|
|
46
|
+
},
|
|
47
|
+
"event": event,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def unwrap_trail_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
"""Return the canonical event from an envelope or a legacy plain record."""
|
|
53
|
+
if not isinstance(record, dict):
|
|
54
|
+
return record
|
|
55
|
+
trail = record.get("trail")
|
|
56
|
+
event = record.get("event")
|
|
57
|
+
if isinstance(trail, dict) and isinstance(event, dict):
|
|
58
|
+
return event
|
|
59
|
+
return record
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_chained_record(record: dict[str, Any]) -> bool:
|
|
63
|
+
trail = record.get("trail") if isinstance(record, dict) else None
|
|
64
|
+
event = record.get("event") if isinstance(record, dict) else None
|
|
65
|
+
return isinstance(trail, dict) and isinstance(event, dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ChainHead:
|
|
70
|
+
seq: int
|
|
71
|
+
last_sha256: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_sidecar(sidecar: Path) -> ChainHead | None:
|
|
75
|
+
if not sidecar.is_file():
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
|
79
|
+
return ChainHead(
|
|
80
|
+
seq=int(data.get("seq", 0)),
|
|
81
|
+
last_sha256=str(data.get("last_sha256") or GENESIS_SHA256),
|
|
82
|
+
)
|
|
83
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _save_sidecar(sidecar: Path, head: ChainHead) -> None:
|
|
88
|
+
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
sidecar.write_text(
|
|
90
|
+
json.dumps({"seq": head.seq, "last_sha256": head.last_sha256}, indent=2) + "\n",
|
|
91
|
+
encoding="utf-8",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _head_from_trail_file(trail_path: Path) -> ChainHead | None:
|
|
96
|
+
if not trail_path.is_file():
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
last_line = ""
|
|
100
|
+
with trail_path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
101
|
+
for line in fh:
|
|
102
|
+
if line.strip():
|
|
103
|
+
last_line = line.strip()
|
|
104
|
+
if not last_line:
|
|
105
|
+
return None
|
|
106
|
+
record = json.loads(last_line)
|
|
107
|
+
if not is_chained_record(record):
|
|
108
|
+
return None
|
|
109
|
+
trail = record["trail"]
|
|
110
|
+
return ChainHead(seq=int(trail["seq"]), last_sha256=str(trail["record_sha256"]))
|
|
111
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_chain_head(trail_path: Path) -> ChainHead:
|
|
116
|
+
sidecar = chain_sidecar_path(trail_path)
|
|
117
|
+
head = _load_sidecar(sidecar)
|
|
118
|
+
if head is not None:
|
|
119
|
+
return head
|
|
120
|
+
recovered = _head_from_trail_file(trail_path)
|
|
121
|
+
if recovered is not None:
|
|
122
|
+
return recovered
|
|
123
|
+
return ChainHead(seq=0, last_sha256=GENESIS_SHA256)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def append_chained_line(trail_path: Path, event: dict[str, Any]) -> ChainHead:
|
|
127
|
+
"""Append one chained envelope line and persist the sidecar head."""
|
|
128
|
+
head = load_chain_head(trail_path)
|
|
129
|
+
next_seq = head.seq + 1
|
|
130
|
+
envelope = wrap_chained_record(next_seq, head.last_sha256, event)
|
|
131
|
+
line = json.dumps(envelope, separators=(",", ":"), default=str) + "\n"
|
|
132
|
+
trail_path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
with trail_path.open("a", encoding="utf-8") as fh:
|
|
134
|
+
fh.write(line)
|
|
135
|
+
new_head = ChainHead(seq=next_seq, last_sha256=envelope["trail"]["record_sha256"])
|
|
136
|
+
_save_sidecar(chain_sidecar_path(trail_path), new_head)
|
|
137
|
+
return new_head
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class TrailVerifyResult:
|
|
142
|
+
ok: bool
|
|
143
|
+
message: str
|
|
144
|
+
lines_total: int = 0
|
|
145
|
+
lines_chained: int = 0
|
|
146
|
+
lines_legacy: int = 0
|
|
147
|
+
first_bad_line: int | None = None
|
|
148
|
+
# The chain head after a successful walk. Printed by the CLI so an operator
|
|
149
|
+
# can record it somewhere the audited agent cannot write and compare later —
|
|
150
|
+
# the only defense a local file can offer against tail deletion.
|
|
151
|
+
head_seq: int = 0
|
|
152
|
+
head_sha256: str = ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def verify_trail_file(trail_path: Path) -> TrailVerifyResult:
|
|
156
|
+
if not trail_path.is_file():
|
|
157
|
+
return TrailVerifyResult(False, f"no such file: {trail_path}")
|
|
158
|
+
|
|
159
|
+
prev = GENESIS_SHA256
|
|
160
|
+
expected_seq = 0
|
|
161
|
+
total = chained = legacy = 0
|
|
162
|
+
first_bad: int | None = None
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
with trail_path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
166
|
+
for line_no, raw in enumerate(fh, start=1):
|
|
167
|
+
line = raw.strip()
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
total += 1
|
|
171
|
+
try:
|
|
172
|
+
record = json.loads(line)
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
return TrailVerifyResult(
|
|
175
|
+
False,
|
|
176
|
+
f"invalid JSON at line {line_no}",
|
|
177
|
+
total,
|
|
178
|
+
chained,
|
|
179
|
+
legacy,
|
|
180
|
+
line_no,
|
|
181
|
+
)
|
|
182
|
+
if not isinstance(record, dict):
|
|
183
|
+
return TrailVerifyResult(
|
|
184
|
+
False,
|
|
185
|
+
f"expected object at line {line_no}",
|
|
186
|
+
total,
|
|
187
|
+
chained,
|
|
188
|
+
legacy,
|
|
189
|
+
line_no,
|
|
190
|
+
)
|
|
191
|
+
if not is_chained_record(record):
|
|
192
|
+
# Legacy lines are only legitimate as a prefix from before
|
|
193
|
+
# chaining was enabled. An unchained line AFTER chained
|
|
194
|
+
# records is exactly what a forged append looks like, and
|
|
195
|
+
# readers would render it as a real event.
|
|
196
|
+
if chained > 0:
|
|
197
|
+
return TrailVerifyResult(
|
|
198
|
+
False,
|
|
199
|
+
f"unchained line after chained records at line {line_no} (forged append?)",
|
|
200
|
+
total,
|
|
201
|
+
chained,
|
|
202
|
+
legacy,
|
|
203
|
+
line_no,
|
|
204
|
+
)
|
|
205
|
+
legacy += 1
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
trail = record["trail"]
|
|
209
|
+
event = record["event"]
|
|
210
|
+
seq = int(trail.get("seq", -1))
|
|
211
|
+
prev_sha = str(trail.get("prev_sha256", ""))
|
|
212
|
+
record_sha = str(trail.get("record_sha256", ""))
|
|
213
|
+
|
|
214
|
+
if seq != expected_seq + 1:
|
|
215
|
+
return TrailVerifyResult(
|
|
216
|
+
False,
|
|
217
|
+
f"sequence break at line {line_no}: expected seq {expected_seq + 1}, got {seq}",
|
|
218
|
+
total,
|
|
219
|
+
chained,
|
|
220
|
+
legacy,
|
|
221
|
+
line_no,
|
|
222
|
+
)
|
|
223
|
+
if prev_sha != prev:
|
|
224
|
+
return TrailVerifyResult(
|
|
225
|
+
False,
|
|
226
|
+
f"prev_sha256 mismatch at line {line_no}",
|
|
227
|
+
total,
|
|
228
|
+
chained,
|
|
229
|
+
legacy,
|
|
230
|
+
line_no,
|
|
231
|
+
)
|
|
232
|
+
expected = compute_record_sha256(prev_sha, event)
|
|
233
|
+
if record_sha != expected:
|
|
234
|
+
return TrailVerifyResult(
|
|
235
|
+
False,
|
|
236
|
+
f"record_sha256 mismatch at line {line_no} (tampered?)",
|
|
237
|
+
total,
|
|
238
|
+
chained,
|
|
239
|
+
legacy,
|
|
240
|
+
line_no,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
chained += 1
|
|
244
|
+
expected_seq = seq
|
|
245
|
+
prev = record_sha
|
|
246
|
+
except OSError as exc:
|
|
247
|
+
return TrailVerifyResult(False, f"read failed: {exc}")
|
|
248
|
+
|
|
249
|
+
if chained == 0 and legacy > 0:
|
|
250
|
+
return TrailVerifyResult(
|
|
251
|
+
True,
|
|
252
|
+
f"legacy trail only — {legacy} unchained line(s); enable export to start chaining",
|
|
253
|
+
total,
|
|
254
|
+
chained,
|
|
255
|
+
legacy,
|
|
256
|
+
)
|
|
257
|
+
if chained == 0:
|
|
258
|
+
return TrailVerifyResult(True, "empty trail", total, chained, legacy)
|
|
259
|
+
|
|
260
|
+
# The chain itself proves in-place edits, inserts, and reordering. It cannot
|
|
261
|
+
# prove the file was not cut short: compare against the sidecar head, which
|
|
262
|
+
# records how far the writer actually got.
|
|
263
|
+
sidecar_head = _load_sidecar(chain_sidecar_path(trail_path))
|
|
264
|
+
if sidecar_head is not None and sidecar_head.seq > expected_seq:
|
|
265
|
+
return TrailVerifyResult(
|
|
266
|
+
False,
|
|
267
|
+
f"trail ends at seq {expected_seq} but sidecar records seq {sidecar_head.seq} (truncated?)",
|
|
268
|
+
total,
|
|
269
|
+
chained,
|
|
270
|
+
legacy,
|
|
271
|
+
)
|
|
272
|
+
if sidecar_head is not None and sidecar_head.seq == expected_seq and sidecar_head.last_sha256 != prev:
|
|
273
|
+
return TrailVerifyResult(
|
|
274
|
+
False,
|
|
275
|
+
f"sidecar head hash does not match the last chained record at seq {expected_seq}",
|
|
276
|
+
total,
|
|
277
|
+
chained,
|
|
278
|
+
legacy,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
prefix = (
|
|
282
|
+
f"{chained} chained line(s) verified; {legacy} legacy unchained prefix line(s)"
|
|
283
|
+
if legacy
|
|
284
|
+
else f"{chained} chained line(s) verified"
|
|
285
|
+
)
|
|
286
|
+
# A missing sidecar is legitimate (verifying a copied .jsonl on another
|
|
287
|
+
# machine) but it means tail deletion cannot be ruled out — say so instead
|
|
288
|
+
# of implying a stronger guarantee than the file alone can carry.
|
|
289
|
+
if sidecar_head is None:
|
|
290
|
+
prefix += "; no sidecar head found, so tail truncation cannot be ruled out"
|
|
291
|
+
return TrailVerifyResult(
|
|
292
|
+
True,
|
|
293
|
+
prefix,
|
|
294
|
+
total,
|
|
295
|
+
chained,
|
|
296
|
+
legacy,
|
|
297
|
+
first_bad,
|
|
298
|
+
head_seq=expected_seq,
|
|
299
|
+
head_sha256=prev,
|
|
300
|
+
)
|