@litfamily/lithermes 1.0.2 → 1.0.3

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.
@@ -1,2044 +0,0 @@
1
- """Approval-gated skill proposals for the LitHermes user skill root.
2
-
3
- This module never calls a model. Review prints a bounded packet; ``propose``
4
- imports schema-shaped JSON; only the foreground ``apply`` command mutates an
5
- agent-owned skill under ``$HERMES_HOME/skills``. The host curator remains the
6
- only curator and can be opted into explicitly with ``hermes curator adopt``.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import hashlib
13
- import json
14
- import os
15
- import re
16
- import shutil
17
- import stat
18
- import sys
19
- import tempfile
20
- import time
21
- import unicodedata
22
- import uuid
23
- from contextlib import contextmanager
24
- from datetime import datetime
25
- from pathlib import Path
26
- from typing import Any, Final
27
-
28
- try:
29
- from .redaction import redact_text
30
- from . import skill_observer
31
- except (ImportError, ModuleNotFoundError):
32
- from redaction import redact_text
33
- import skill_observer
34
-
35
- try:
36
- import fcntl
37
- except ImportError: # pragma: no cover - the supported Hermes runtime is POSIX
38
- fcntl = None
39
-
40
-
41
- PROPOSAL_SCHEMA: Final = "litfamily.skill-proposal/v1"
42
- LEDGER_SCHEMA: Final = "litfamily.skill-ledger/v1"
43
- HOST: Final = "lithermes"
44
- MARKER: Final = "lithermesAgentGenerated"
45
- AUTO_APPLY: Final = False
46
- MAX_PROPOSAL_BYTES: Final = 1024 * 1024
47
- MAX_PROPOSAL_FILES: Final = 128
48
- MAX_PROPOSAL_QUEUE_BYTES: Final = 4 * 1024 * 1024
49
- MAX_SKILL_FILE_BYTES: Final = 4 * 1024 * 1024
50
- MAX_SKILL_TOTAL_BYTES: Final = 16 * 1024 * 1024
51
- MAX_REVIEW_PACKET_BYTES: Final = 64 * 1024
52
- MAX_REVIEW_LINES: Final = 20
53
- MAX_SESSIONS: Final = 64
54
- MAX_CONSULTED_SKILLS: Final = 8
55
- MAX_CORRECTIONS_PER_SESSION: Final = 3
56
- _ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
57
- _SKILL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
58
- _OBSERVED_SKILL_ID = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
59
- _CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
60
- _CORRECTION = re.compile(
61
- r"(?:\bdon['’]?t\b|\bstop\b|\bdo not\b|\bnot that\b|"
62
- r"하지\s*마|그만|왜\s*자꾸|아니라|말고|빼(?:줘|세요)|요약하지)",
63
- re.IGNORECASE,
64
- )
65
- _RESERVED = re.compile(r"^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", re.IGNORECASE)
66
- _PROPOSAL_FIELDS = {
67
- "schema", "id", "createdAt", "host", "sessionRef", "signal", "targetSkill",
68
- "targetRoot", "action", "patch", "createSpec", "rationale", "evidenceRefs",
69
- "status", "ledgerEntryId",
70
- }
71
- _LEDGER_FIELDS = {
72
- "schema", "id", "createdAt", "proposalId", "actor", "operation",
73
- "targetSkill", "targetRoot", "before", "after", "restoresLedgerEntryId",
74
- "reason",
75
- }
76
- _CONSULTED: dict[str, list[str]] = {}
77
- _CORRECTION_DIGESTS: dict[str, list[str]] = {}
78
- _PENDING_REVIEW: set[str] = set()
79
-
80
-
81
- class SkillLoopError(Exception):
82
- def __init__(self, code: str, message: str) -> None:
83
- super().__init__(message)
84
- self.code = code
85
-
86
-
87
- def _fail(code: str, message: str) -> None:
88
- raise SkillLoopError(code, message)
89
-
90
-
91
- def _hermes_home(value: str | os.PathLike[str] | None = None) -> Path:
92
- raw = value or os.environ.get("HERMES_HOME")
93
- if not raw:
94
- raw = Path.home() / ".hermes"
95
- path = Path(raw).expanduser()
96
- try:
97
- return path.resolve()
98
- except (OSError, RuntimeError):
99
- _fail("HERMES_HOME_UNSAFE", "the Hermes home cannot be resolved")
100
-
101
-
102
- def _workspace(value: str | os.PathLike[str] | None = None) -> Path:
103
- path = Path(value or os.getcwd())
104
- try:
105
- resolved = path.resolve(strict=True)
106
- except (OSError, RuntimeError):
107
- _fail("WORKSPACE_UNSAFE", "the workspace cannot be resolved")
108
- if not resolved.is_dir():
109
- _fail("WORKSPACE_UNSAFE", "the workspace is not a directory")
110
- return resolved
111
-
112
-
113
- def proposal_directory(workspace: str | os.PathLike[str] | None = None) -> Path:
114
- current = _workspace(workspace)
115
- for component in (".hermes", "lithermes", "skill-proposals"):
116
- current = current / component
117
- if current.is_symlink():
118
- _fail("STATE_PATH_UNSAFE", "the workspace proposal path cannot contain symbolic links")
119
- if current.exists() and not current.is_dir():
120
- _fail("STATE_PATH_UNSAFE", "the workspace proposal path is not a directory")
121
- return current
122
-
123
-
124
- def ledger_path(hermes_home: str | os.PathLike[str] | None = None) -> Path:
125
- return _hermes_home(hermes_home) / "lithermes" / "skill-loop-ledger.jsonl"
126
-
127
-
128
- def blob_directory(hermes_home: str | os.PathLike[str] | None = None) -> Path:
129
- return _hermes_home(hermes_home) / "lithermes" / "skill-loop-blobs"
130
-
131
-
132
- def skills_root(hermes_home: str | os.PathLike[str] | None = None) -> Path:
133
- return _hermes_home(hermes_home) / "skills"
134
-
135
-
136
- def _state_root(hermes_home: str | os.PathLike[str] | None = None) -> Path:
137
- return _hermes_home(hermes_home) / "lithermes"
138
-
139
-
140
- def _transaction_path(hermes_home: str | os.PathLike[str] | None = None) -> Path:
141
- return _state_root(hermes_home) / "skill-loop-transaction.json"
142
-
143
-
144
- def _set_private_directory(path: Path) -> None:
145
- path.mkdir(parents=True, exist_ok=True, mode=0o700)
146
- if path.is_symlink() or not path.is_dir():
147
- _fail("STATE_PATH_UNSAFE", "a skill-loop state path is not a directory")
148
- try:
149
- path.chmod(0o700)
150
- except OSError:
151
- _fail("STATE_PATH_UNSAFE", "a skill-loop state directory is not private")
152
-
153
-
154
- def _ensure_user_skill_root(path: Path) -> None:
155
- """Create the user skill root, but do not rewrite an existing host mode."""
156
- if path.is_symlink():
157
- _fail("TARGET_PATH_UNSAFE", "the Hermes user skill root cannot be a symbolic link")
158
- if path.exists():
159
- if not path.is_dir():
160
- _fail("TARGET_PATH_UNSAFE", "the Hermes user skill root is not a directory")
161
- return
162
- try:
163
- path.mkdir(parents=True, mode=0o700)
164
- except FileExistsError:
165
- if path.is_symlink() or not path.is_dir():
166
- _fail("TARGET_PATH_UNSAFE", "the Hermes user skill root changed during validation")
167
- except OSError:
168
- _fail("TARGET_PATH_UNSAFE", "the Hermes user skill root could not be created")
169
-
170
-
171
- def _atomic_write(path: Path, data: bytes, *, mode: int = 0o600) -> None:
172
- _set_private_directory(path.parent)
173
- if path.exists() and (path.is_symlink() or not path.is_file()):
174
- _fail("STATE_PATH_UNSAFE", "a skill-loop state file is not regular")
175
- descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
176
- temp_path = Path(temporary)
177
- try:
178
- os.fchmod(descriptor, mode)
179
- with os.fdopen(descriptor, "wb", closefd=True) as stream:
180
- stream.write(data)
181
- stream.flush()
182
- os.fsync(stream.fileno())
183
- os.replace(temp_path, path)
184
- directory_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
185
- try:
186
- os.fsync(directory_fd)
187
- finally:
188
- os.close(directory_fd)
189
- finally:
190
- if temp_path.exists():
191
- temp_path.unlink()
192
-
193
-
194
- def _json_bytes(record: Any) -> bytes:
195
- return (json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
196
-
197
-
198
- def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
199
- result: dict[str, Any] = {}
200
- for key, value in pairs:
201
- if key in result:
202
- raise ValueError("duplicate JSON key")
203
- result[key] = value
204
- return result
205
-
206
-
207
- def _read_json_file(path: Path, *, maximum: int = MAX_PROPOSAL_BYTES) -> Any:
208
- try:
209
- value = path.lstat()
210
- if stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode) or value.st_nlink != 1:
211
- _fail("STATE_PATH_UNSAFE", "the JSON input is not a regular single-link file")
212
- if value.st_size > maximum:
213
- _fail("PROPOSAL_TOO_LARGE", "the JSON input exceeds the size limit")
214
- raw = path.read_bytes()
215
- text = raw.decode("utf-8")
216
- return json.loads(text, object_pairs_hook=_reject_duplicate_keys)
217
- except SkillLoopError:
218
- raise
219
- except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):
220
- _fail("PROPOSAL_SCHEMA_INVALID", "the JSON input is malformed")
221
-
222
-
223
- def _strict_timestamp(value: Any) -> bool:
224
- if not isinstance(value, str) or not value or len(value) > 64:
225
- return False
226
- if re.fullmatch(
227
- r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}"
228
- r"(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})",
229
- value,
230
- ) is None:
231
- return False
232
- try:
233
- datetime.fromisoformat(value.replace("Z", "+00:00"))
234
- except ValueError:
235
- return False
236
- return True
237
-
238
-
239
- def _canonical_component(value: str) -> str:
240
- return unicodedata.normalize("NFKC", value).casefold().rstrip(". ")
241
-
242
-
243
- def _portable_skill_identity(value: str, *, code: str, message: str) -> str:
244
- identity = _canonical_component(value)
245
- stem = value.split(".", 1)[0]
246
- if (
247
- not identity
248
- or identity != unicodedata.normalize("NFKC", value).casefold()
249
- or _RESERVED.fullmatch(stem) is not None
250
- ):
251
- _fail(code, message)
252
- return identity
253
-
254
-
255
- def _validate_relative_file(value: Any, target_skill: str) -> str:
256
- if not isinstance(value, str) or not value or len(value) > 1024:
257
- _fail("PROPOSAL_PATH_INVALID", "proposal files must be bounded relative paths")
258
- if value.startswith(('/', '\\')) or re.match(r"^[A-Za-z]:", value):
259
- _fail("PROPOSAL_PATH_INVALID", "proposal files must be relative")
260
- if "\\" in value or _CONTROL.search(value) or any(c in value for c in ':*?"<>|'):
261
- _fail("PROPOSAL_PATH_INVALID", "proposal files must use portable path characters")
262
- parts = value.split("/")
263
- if any(not part or part in {".", ".."} or part.endswith((".", " ")) for part in parts):
264
- _fail("PROPOSAL_PATH_INVALID", "proposal files contain an unsafe segment")
265
- for part in parts:
266
- stem = part.split(".", 1)[0]
267
- if _RESERVED.fullmatch(stem):
268
- _fail("PROPOSAL_PATH_INVALID", "proposal files contain a reserved basename")
269
- identity = "/".join(_canonical_component(part) for part in parts)
270
- target_identity = _canonical_component(target_skill)
271
- if identity == target_identity or identity.startswith(f"{target_identity}/"):
272
- _fail("PROPOSAL_PATH_INVALID", "proposal files are relative to the selected skill")
273
- return value
274
-
275
-
276
- def _secret_in(value: Any) -> bool:
277
- if isinstance(value, str):
278
- try:
279
- return skill_observer.contains_secret(value)
280
- except skill_observer.SkillObserverError:
281
- return True
282
- if isinstance(value, list):
283
- return any(_secret_in(item) for item in value)
284
- if isinstance(value, dict):
285
- return any(_secret_in(key) or _secret_in(item) for key, item in value.items())
286
- return False
287
-
288
-
289
- def validate_proposal(record: Any) -> dict[str, Any]:
290
- if not isinstance(record, dict) or set(record) - _PROPOSAL_FIELDS:
291
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal fields do not match version 1")
292
- required = {
293
- "schema", "id", "createdAt", "host", "sessionRef", "signal", "targetSkill",
294
- "targetRoot", "action", "rationale", "evidenceRefs", "status",
295
- }
296
- if not required.issubset(record):
297
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal required fields are missing")
298
- if record["schema"] != PROPOSAL_SCHEMA or record["host"] != HOST:
299
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal schema or host is invalid")
300
- if not isinstance(record["id"], str) or _ID.fullmatch(record["id"]) is None:
301
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal id is invalid")
302
- if not _strict_timestamp(record["createdAt"]):
303
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal timestamp is invalid")
304
- if not isinstance(record["sessionRef"], str) or not 1 <= len(record["sessionRef"]) <= 512:
305
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal session reference is invalid")
306
- if not isinstance(record["signal"], str) or not 1 <= len(record["signal"]) <= 128:
307
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal signal is invalid")
308
- target = record["targetSkill"]
309
- if not isinstance(target, str) or _SKILL_ID.fullmatch(target) is None:
310
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal target skill is invalid")
311
- _portable_skill_identity(
312
- target,
313
- code="PROPOSAL_PATH_INVALID",
314
- message="proposal target skill is not a portable name",
315
- )
316
- if not isinstance(record["targetRoot"], str) or not 1 <= len(record["targetRoot"]) <= 2048:
317
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal target root is invalid")
318
- if not isinstance(record["rationale"], str) or not 1 <= len(record["rationale"]) <= 8192:
319
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal rationale is invalid")
320
- refs = record["evidenceRefs"]
321
- if (
322
- not isinstance(refs, list) or len(refs) > 32 or len(set(map(str, refs))) != len(refs)
323
- or any(not isinstance(item, str) or not 1 <= len(item) <= 2048 for item in refs)
324
- ):
325
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal evidence references are invalid")
326
- if record["status"] not in {"pending", "approved", "applied", "rejected", "rolled-back"}:
327
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal status is invalid")
328
- if record["status"] in {"applied", "rolled-back"}:
329
- if not isinstance(record.get("ledgerEntryId"), str) or not record["ledgerEntryId"]:
330
- _fail("PROPOSAL_SCHEMA_INVALID", "a terminal proposal requires a ledger entry")
331
- elif "ledgerEntryId" in record:
332
- _fail("PROPOSAL_SCHEMA_INVALID", "a non-terminal proposal cannot name a ledger entry")
333
-
334
- action = record["action"]
335
- if action not in {"patch", "create", "add-reference", "archive"}:
336
- _fail("PROPOSAL_SCHEMA_INVALID", "proposal action is invalid")
337
- if action == "patch":
338
- if "createSpec" in record or not isinstance(record.get("patch"), dict):
339
- _fail("PROPOSAL_SCHEMA_INVALID", "patch proposal content is invalid")
340
- patch = record["patch"]
341
- if set(patch) != {"file", "oldString", "newString"}:
342
- _fail("PROPOSAL_SCHEMA_INVALID", "patch proposal fields are invalid")
343
- _validate_relative_file(patch["file"], target)
344
- if (
345
- not isinstance(patch["oldString"], str) or not patch["oldString"]
346
- or len(patch["oldString"]) > 65536
347
- or not isinstance(patch["newString"], str) or len(patch["newString"]) > 65536
348
- ):
349
- _fail("PROPOSAL_SCHEMA_INVALID", "patch strings are invalid")
350
- elif action in {"create", "add-reference"}:
351
- if "patch" in record or not isinstance(record.get("createSpec"), dict):
352
- _fail("PROPOSAL_SCHEMA_INVALID", "create proposal content is invalid")
353
- spec = record["createSpec"]
354
- if set(spec) != {"files"} or not isinstance(spec["files"], list) or not 1 <= len(spec["files"]) <= 64:
355
- _fail("PROPOSAL_SCHEMA_INVALID", "create proposal files are invalid")
356
- identities: set[str] = set()
357
- total = 0
358
- for item in spec["files"]:
359
- if not isinstance(item, dict) or set(item) != {"file", "content"}:
360
- _fail("PROPOSAL_SCHEMA_INVALID", "create proposal file fields are invalid")
361
- file = _validate_relative_file(item["file"], target)
362
- identity = "/".join(_canonical_component(part) for part in file.split("/"))
363
- if identity in identities:
364
- _fail("PROPOSAL_PATH_INVALID", "create proposal paths are aliases")
365
- identities.add(identity)
366
- if not isinstance(item["content"], str) or len(item["content"]) > 65536:
367
- _fail("PROPOSAL_SCHEMA_INVALID", "create proposal file content is invalid")
368
- total += len(item["content"].encode("utf-8"))
369
- if total > MAX_SKILL_TOTAL_BYTES:
370
- _fail("PROPOSAL_TOO_LARGE", "proposal content exceeds the total limit")
371
- elif "patch" in record or "createSpec" in record:
372
- _fail("PROPOSAL_SCHEMA_INVALID", "archive proposals carry no content")
373
- if _secret_in(record):
374
- _fail("PROPOSAL_SECRET_REFUSED", "credential-shaped proposal text was refused")
375
- return record
376
-
377
-
378
- def _proposal_path(proposal_id: str, workspace: str | os.PathLike[str] | None = None) -> Path:
379
- if _ID.fullmatch(str(proposal_id)) is None:
380
- _fail("PROPOSAL_ID_INVALID", "proposal id is invalid")
381
- return proposal_directory(workspace) / f"{proposal_id}.json"
382
-
383
-
384
- def _validate_terminal_proposal_receipt(
385
- record: dict[str, Any],
386
- hermes_home: str | os.PathLike[str] | None,
387
- ) -> None:
388
- expected_operation = {"applied": "apply", "rolled-back": "rollback"}.get(record["status"])
389
- if expected_operation is None:
390
- return
391
- receipt = next(
392
- (
393
- entry
394
- for entry in read_ledger(hermes_home=hermes_home)
395
- if entry["id"] == record["ledgerEntryId"]
396
- ),
397
- None,
398
- )
399
- if receipt is None:
400
- _fail("PROPOSAL_LEDGER_MISMATCH", "the terminal proposal ledger receipt is unavailable")
401
- proposal_target = _canonical_ledger_target(
402
- record,
403
- code="PROPOSAL_LEDGER_MISMATCH",
404
- message="the terminal proposal target is not canonical",
405
- )
406
- receipt_target = _canonical_ledger_target(
407
- receipt,
408
- code="PROPOSAL_LEDGER_MISMATCH",
409
- message="the terminal proposal ledger target is not canonical",
410
- )
411
- if (
412
- receipt["operation"] != expected_operation
413
- or receipt["proposalId"] != record["id"]
414
- or receipt_target != proposal_target
415
- ):
416
- _fail("PROPOSAL_LEDGER_MISMATCH", "the terminal proposal does not match its ledger receipt")
417
-
418
-
419
- def _write_proposal(
420
- record: dict[str, Any],
421
- workspace: str | os.PathLike[str] | None = None,
422
- hermes_home: str | os.PathLike[str] | None = None,
423
- ) -> None:
424
- validate_proposal(record)
425
- _validate_terminal_proposal_receipt(record, hermes_home)
426
- _atomic_write(_proposal_path(record["id"], workspace), _json_bytes(record))
427
-
428
-
429
- def load_proposal(
430
- proposal_id: str,
431
- workspace: str | os.PathLike[str] | None = None,
432
- hermes_home: str | os.PathLike[str] | None = None,
433
- ) -> dict[str, Any]:
434
- path = _proposal_path(proposal_id, workspace)
435
- if not path.exists():
436
- _fail("PROPOSAL_NOT_FOUND", "the proposal does not exist in this workspace")
437
- record = _read_json_file(path)
438
- validate_proposal(record)
439
- _validate_terminal_proposal_receipt(record, hermes_home)
440
- return record
441
-
442
-
443
- def propose(
444
- file: str | os.PathLike[str], *, workspace: str | os.PathLike[str] | None = None,
445
- hermes_home: str | os.PathLike[str] | None = None,
446
- ) -> dict[str, Any]:
447
- # targetRoot stays untrusted data until foreground apply.
448
- record = validate_proposal(_read_json_file(Path(file).expanduser()))
449
- if record["status"] != "pending":
450
- _fail("PROPOSAL_STATUS_INVALID", "imported proposals must be pending")
451
- destination = _proposal_path(record["id"], workspace)
452
- if destination.exists():
453
- _fail("PROPOSAL_EXISTS", "a proposal with this id already exists")
454
- _write_proposal(record, workspace, hermes_home)
455
- return {"ok": True, "id": record["id"]}
456
-
457
-
458
- def list_proposals(
459
- *, workspace: str | os.PathLike[str] | None = None,
460
- hermes_home: str | os.PathLike[str] | None = None,
461
- ) -> list[dict[str, Any]]:
462
- directory = proposal_directory(workspace)
463
- if not directory.exists():
464
- return []
465
- if directory.is_symlink() or not directory.is_dir():
466
- _fail("STATE_PATH_UNSAFE", "the proposal queue is not a directory")
467
- paths: list[Path] = []
468
- aggregate = 0
469
- try:
470
- with os.scandir(directory) as entries:
471
- for item in entries:
472
- if len(paths) >= MAX_PROPOSAL_FILES:
473
- _fail("PROPOSAL_QUEUE_TOO_LARGE", "the proposal queue exceeds the file-count limit")
474
- if not item.name.endswith(".json"):
475
- _fail("STATE_PATH_UNSAFE", "the proposal queue contains an unexpected entry")
476
- value = item.stat(follow_symlinks=False)
477
- if stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode) or value.st_nlink != 1:
478
- _fail("STATE_PATH_UNSAFE", "a queued proposal is not a regular single-link file")
479
- aggregate += value.st_size
480
- if aggregate > MAX_PROPOSAL_QUEUE_BYTES:
481
- _fail("PROPOSAL_QUEUE_TOO_LARGE", "the proposal queue exceeds the aggregate byte limit")
482
- paths.append(Path(item.path))
483
- except SkillLoopError:
484
- raise
485
- except OSError:
486
- _fail("STATE_PATH_UNSAFE", "the proposal queue cannot be inspected")
487
- paths.sort()
488
- records: list[dict[str, Any]] = []
489
- for path in paths:
490
- record = validate_proposal(_read_json_file(path))
491
- _validate_terminal_proposal_receipt(record, hermes_home)
492
- records.append(record)
493
- return records
494
-
495
-
496
- def _private_ledger_bytes(path: Path) -> bytes:
497
- if not path.exists():
498
- return b""
499
- try:
500
- value = path.lstat()
501
- if (
502
- stat.S_ISLNK(value.st_mode)
503
- or not stat.S_ISREG(value.st_mode)
504
- or value.st_nlink != 1
505
- or value.st_mode & 0o077
506
- ):
507
- _fail("LEDGER_MALFORMED", "the decision ledger path is unsafe")
508
- raw = path.read_bytes()
509
- except SkillLoopError:
510
- raise
511
- except OSError:
512
- _fail("LEDGER_MALFORMED", "the decision ledger could not be read safely")
513
- if len(raw) > 8 * 1024 * 1024:
514
- _fail("LEDGER_MALFORMED", "the decision ledger exceeds the size limit")
515
- return raw
516
-
517
-
518
- def _parse_ledger_bytes(
519
- raw: bytes,
520
- hermes_home: str | os.PathLike[str] | None,
521
- ) -> list[dict[str, Any]]:
522
- try:
523
- records = [
524
- json.loads(line, object_pairs_hook=_reject_duplicate_keys)
525
- for line in raw.decode("utf-8").splitlines()
526
- if line.strip()
527
- ]
528
- except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
529
- _fail("LEDGER_MALFORMED", "the decision ledger is malformed")
530
- seen: set[str] = set()
531
- for record in records:
532
- validate_ledger_entry(record)
533
- identifier = record.get("id")
534
- if not isinstance(identifier, str) or identifier in seen:
535
- _fail("LEDGER_MALFORMED", "the decision ledger contains a duplicate id")
536
- seen.add(identifier)
537
- _validate_ledger_integrity(records, hermes_home)
538
- return records
539
-
540
-
541
- def _append_jsonl(
542
- path: Path,
543
- record: dict[str, Any],
544
- hermes_home: str | os.PathLike[str] | None = None,
545
- ) -> None:
546
- validate_ledger_entry(record)
547
- payload = _json_bytes(record)
548
- home = hermes_home if hermes_home is not None else path.parent.parent
549
- try:
550
- existing = _private_ledger_bytes(path)
551
- if existing and not existing.endswith(b"\n"):
552
- _fail("LEDGER_MALFORMED", "the decision ledger has an incomplete tail")
553
- records = _parse_ledger_bytes(existing, home)
554
- if any(item["id"] == record["id"] for item in records):
555
- _fail("LEDGER_MALFORMED", "the decision ledger contains a duplicate id")
556
- _validate_ledger_integrity([*records, record], home)
557
- combined = existing + payload
558
- if len(combined) > 8 * 1024 * 1024:
559
- _fail("LEDGER_WRITE_FAILED", "the decision ledger exceeds the size limit")
560
- _atomic_write(path, combined)
561
- except SkillLoopError as error:
562
- if error.code in {"LEDGER_MALFORMED", "BLOB_MISSING", "BLOB_STORE_CORRUPT"}:
563
- raise
564
- _fail("LEDGER_WRITE_FAILED", "the decision ledger could not be replaced atomically")
565
- except OSError:
566
- _fail("LEDGER_WRITE_FAILED", "the decision ledger could not be replaced atomically")
567
-
568
-
569
- def _validate_snapshot(value: Any) -> list[dict[str, Any]]:
570
- if not isinstance(value, list) or len(value) > 256:
571
- _fail("LEDGER_MALFORMED", "a decision ledger snapshot is invalid")
572
- identities: set[str] = set()
573
- for item in value:
574
- if not isinstance(item, dict) or set(item) != {"file", "blob"}:
575
- _fail("LEDGER_MALFORMED", "a decision ledger snapshot item is invalid")
576
- try:
577
- file = _validate_relative_file(item.get("file"), "ledger-snapshot")
578
- except SkillLoopError:
579
- _fail("LEDGER_MALFORMED", "a decision ledger snapshot path is invalid")
580
- identity = "/".join(_canonical_component(part) for part in file.split("/"))
581
- if identity in identities:
582
- _fail("LEDGER_MALFORMED", "a decision ledger snapshot contains path aliases")
583
- identities.add(identity)
584
- blob = item.get("blob")
585
- if (
586
- not isinstance(blob, dict)
587
- or set(blob) != {"algorithm", "digest", "size"}
588
- or blob.get("algorithm") != "sha256"
589
- or not isinstance(blob.get("digest"), str)
590
- or re.fullmatch(r"[a-f0-9]{64}", blob["digest"]) is None
591
- or not isinstance(blob.get("size"), int)
592
- or isinstance(blob.get("size"), bool)
593
- or blob["size"] < 0
594
- ):
595
- _fail("LEDGER_MALFORMED", "a decision ledger blob record is invalid")
596
- return value
597
-
598
-
599
- def validate_ledger_entry(record: Any) -> dict[str, Any]:
600
- if not isinstance(record, dict) or set(record) - _LEDGER_FIELDS:
601
- _fail("LEDGER_MALFORMED", "a decision ledger record has unknown fields")
602
- required = {
603
- "schema", "id", "createdAt", "proposalId", "actor", "operation",
604
- "targetSkill", "targetRoot", "before", "after",
605
- }
606
- if not required.issubset(record) or record.get("schema") != LEDGER_SCHEMA:
607
- _fail("LEDGER_MALFORMED", "a decision ledger record is missing required fields")
608
- if not isinstance(record.get("id"), str) or _ID.fullmatch(record["id"]) is None:
609
- _fail("LEDGER_MALFORMED", "a decision ledger id is invalid")
610
- if not _strict_timestamp(record.get("createdAt")):
611
- _fail("LEDGER_MALFORMED", "a decision ledger timestamp is invalid")
612
- proposal_id = record.get("proposalId")
613
- if proposal_id is not None and (
614
- not isinstance(proposal_id, str) or not 1 <= len(proposal_id) <= 128
615
- ):
616
- _fail("LEDGER_MALFORMED", "a decision ledger proposal id is invalid")
617
- if record.get("actor") not in {"user", "curator"}:
618
- _fail("LEDGER_MALFORMED", "a decision ledger actor is invalid")
619
- operation = record.get("operation")
620
- if operation not in {"apply", "reject", "rollback", "curator-transition"}:
621
- _fail("LEDGER_MALFORMED", "a decision ledger operation is invalid")
622
- if not isinstance(record.get("targetSkill"), str) or _SKILL_ID.fullmatch(record["targetSkill"]) is None:
623
- _fail("LEDGER_MALFORMED", "a decision ledger target skill is invalid")
624
- if not isinstance(record.get("targetRoot"), str) or not 1 <= len(record["targetRoot"]) <= 2048:
625
- _fail("LEDGER_MALFORMED", "a decision ledger target root is invalid")
626
- _validate_snapshot(record.get("before"))
627
- _validate_snapshot(record.get("after"))
628
- reason = record.get("reason")
629
- if reason is not None and (not isinstance(reason, str) or len(reason) > 8192):
630
- _fail("LEDGER_MALFORMED", "a decision ledger reason is invalid")
631
- restores = record.get("restoresLedgerEntryId")
632
- if operation == "rollback":
633
- if not isinstance(restores, str) or not 1 <= len(restores) <= 128:
634
- _fail("LEDGER_MALFORMED", "a rollback ledger record lacks its restored entry id")
635
- elif restores is not None:
636
- _fail("LEDGER_MALFORMED", "a non-rollback ledger record names a restored entry")
637
- if operation in {"apply", "reject"} and (
638
- record.get("actor") != "user" or not isinstance(proposal_id, str)
639
- ):
640
- _fail("LEDGER_MALFORMED", "an apply or reject ledger record has invalid authority")
641
- if operation == "rollback" and record.get("actor") != "user":
642
- _fail("LEDGER_MALFORMED", "a rollback ledger record has invalid authority")
643
- if operation == "curator-transition" and (
644
- record.get("actor") != "curator" or proposal_id is not None
645
- ):
646
- _fail("LEDGER_MALFORMED", "a curator ledger record has invalid authority")
647
- return record
648
-
649
-
650
- def _canonical_ledger_target(
651
- record: dict[str, Any],
652
- *,
653
- code: str = "LEDGER_MALFORMED",
654
- message: str = "a decision ledger target is not canonical",
655
- ) -> tuple[str, str]:
656
- target_skill = record.get("targetSkill")
657
- target_root = record.get("targetRoot")
658
- if not isinstance(target_skill, str) or not isinstance(target_root, str):
659
- _fail(code, message)
660
- skill_identity = _portable_skill_identity(target_skill, code=code, message=message)
661
- try:
662
- root = Path(target_root)
663
- if not root.is_absolute():
664
- _fail(code, message)
665
- root_identity = str(root.resolve(strict=False))
666
- except (OSError, RuntimeError, ValueError):
667
- _fail(code, message)
668
- return skill_identity, root_identity
669
-
670
-
671
- def read_ledger(*, hermes_home: str | os.PathLike[str] | None = None) -> list[dict[str, Any]]:
672
- path = ledger_path(hermes_home)
673
- if not path.exists():
674
- return []
675
- raw = _private_ledger_bytes(path)
676
- if raw and not raw.endswith(b"\n"):
677
- _fail("LEDGER_MALFORMED", "the decision ledger has an incomplete tail")
678
- return _parse_ledger_bytes(raw, hermes_home)
679
-
680
-
681
- def _blob_record(data: bytes, hermes_home: str | os.PathLike[str] | None) -> dict[str, Any]:
682
- digest = hashlib.sha256(data).hexdigest()
683
- directory = blob_directory(hermes_home)
684
- _set_private_directory(directory)
685
- path = directory / digest
686
- if path.exists():
687
- if path.is_symlink() or not path.is_file() or path.read_bytes() != data:
688
- _fail("BLOB_STORE_CORRUPT", "a content-addressed blob does not match its name")
689
- else:
690
- _atomic_write(path, data)
691
- return {"algorithm": "sha256", "digest": digest, "size": len(data)}
692
-
693
-
694
- def _snapshot(
695
- target: Path,
696
- hermes_home: str | os.PathLike[str] | None,
697
- *,
698
- persist_blobs: bool = True,
699
- ) -> list[dict[str, Any]]:
700
- if not target.exists():
701
- return []
702
- if target.is_symlink() or not target.is_dir():
703
- _fail("TARGET_PATH_UNSAFE", "the skill target is not a directory")
704
- records: list[dict[str, Any]] = []
705
- total = 0
706
- for path in sorted(target.rglob("*")):
707
- value = path.lstat()
708
- if stat.S_ISLNK(value.st_mode):
709
- _fail("TARGET_PATH_UNSAFE", "skill packages cannot contain symbolic links")
710
- if path.is_dir():
711
- continue
712
- if not stat.S_ISREG(value.st_mode) or value.st_nlink != 1 or value.st_size > MAX_SKILL_FILE_BYTES:
713
- _fail("TARGET_PATH_UNSAFE", "skill packages must contain bounded regular files")
714
- relative = path.relative_to(target).as_posix()
715
- _validate_relative_file(relative, target.name)
716
- data = path.read_bytes()
717
- total += len(data)
718
- if total > MAX_SKILL_TOTAL_BYTES or len(records) >= 256:
719
- _fail("TARGET_TOO_LARGE", "the skill package exceeds the snapshot limit")
720
- blob = (
721
- _blob_record(data, hermes_home)
722
- if persist_blobs
723
- else {
724
- "algorithm": "sha256",
725
- "digest": hashlib.sha256(data).hexdigest(),
726
- "size": len(data),
727
- }
728
- )
729
- records.append({"file": relative, "blob": blob})
730
- return records
731
-
732
-
733
- def _snapshot_identity(snapshot: list[dict[str, Any]]) -> list[tuple[str, str, int]]:
734
- return sorted((item["file"], item["blob"]["digest"], item["blob"]["size"]) for item in snapshot)
735
-
736
-
737
- def _path_matches_snapshot(
738
- path: Path,
739
- expected: Any,
740
- hermes_home: str | os.PathLike[str] | None,
741
- ) -> bool:
742
- snapshot = _validate_snapshot(expected)
743
- present = path.exists() or path.is_symlink()
744
- if not snapshot:
745
- return not present
746
- if not present:
747
- return False
748
- try:
749
- current = _snapshot(path, hermes_home, persist_blobs=False)
750
- except SkillLoopError:
751
- return False
752
- return _snapshot_identity(current) == _snapshot_identity(snapshot)
753
-
754
-
755
- def _blob_bytes(record: dict[str, Any], hermes_home: str | os.PathLike[str] | None) -> bytes:
756
- try:
757
- digest = record["digest"]
758
- size = record["size"]
759
- except (KeyError, TypeError):
760
- _fail("BLOB_STORE_CORRUPT", "a ledger blob record is malformed")
761
- if not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest) or not isinstance(size, int):
762
- _fail("BLOB_STORE_CORRUPT", "a ledger blob record is malformed")
763
- path = blob_directory(hermes_home) / digest
764
- try:
765
- value = path.lstat()
766
- if (
767
- stat.S_ISLNK(value.st_mode)
768
- or not stat.S_ISREG(value.st_mode)
769
- or value.st_nlink != 1
770
- or value.st_mode & 0o077
771
- ):
772
- raise OSError
773
- data = path.read_bytes()
774
- except OSError:
775
- _fail("BLOB_MISSING", "a rollback blob is unavailable")
776
- if len(data) != size or hashlib.sha256(data).hexdigest() != digest:
777
- _fail("BLOB_STORE_CORRUPT", "a rollback blob failed verification")
778
- return data
779
-
780
-
781
- def _validate_snapshot_blobs(
782
- snapshot: Any,
783
- hermes_home: str | os.PathLike[str] | None,
784
- verified: set[tuple[str, int]] | None = None,
785
- ) -> list[dict[str, Any]]:
786
- records = _validate_snapshot(snapshot)
787
- cache = verified if verified is not None else set()
788
- for item in records:
789
- blob = item["blob"]
790
- identity = (blob["digest"], blob["size"])
791
- if identity in cache:
792
- continue
793
- _blob_bytes(blob, hermes_home)
794
- cache.add(identity)
795
- return records
796
-
797
-
798
- def _validate_ledger_integrity(
799
- records: list[dict[str, Any]],
800
- hermes_home: str | os.PathLike[str] | None,
801
- ) -> None:
802
- by_id = {record["id"]: record for record in records}
803
- positions = {record["id"]: index for index, record in enumerate(records)}
804
- edges: dict[str, str] = {}
805
- for record in records:
806
- if record["operation"] != "rollback":
807
- continue
808
- target = record["restoresLedgerEntryId"]
809
- if target not in by_id:
810
- _fail("LEDGER_MALFORMED", "a rollback ledger reference is missing")
811
- edges[record["id"]] = target
812
-
813
- visited: set[str] = set()
814
- for identifier in edges:
815
- chain: set[str] = set()
816
- current = identifier
817
- while current in edges and current not in visited:
818
- if current in chain:
819
- _fail("LEDGER_MALFORMED", "the decision ledger contains a rollback reference cycle")
820
- chain.add(current)
821
- current = edges[current]
822
- visited.update(chain)
823
-
824
- for identifier, target in edges.items():
825
- referenced = by_id[target]
826
- rollback = by_id[identifier]
827
- if (
828
- referenced["operation"] not in {"apply", "curator-transition", "rollback"}
829
- or positions[target] >= positions[identifier]
830
- ):
831
- _fail("LEDGER_MALFORMED", "a rollback must restore an earlier mutation entry")
832
- if _canonical_ledger_target(rollback) != _canonical_ledger_target(referenced):
833
- _fail("LEDGER_MALFORMED", "a rollback reference does not match its canonical target")
834
- if rollback["proposalId"] != referenced["proposalId"]:
835
- _fail("LEDGER_MALFORMED", "a rollback reference does not match mutation authority")
836
- if (
837
- _snapshot_identity(rollback["before"])
838
- != _snapshot_identity(referenced["after"])
839
- or _snapshot_identity(rollback["after"])
840
- != _snapshot_identity(referenced["before"])
841
- ):
842
- _fail("LEDGER_MALFORMED", "a rollback reference is not the exact snapshot inverse")
843
-
844
- verified: set[tuple[str, int]] = set()
845
- for record in records:
846
- _validate_snapshot_blobs(record["before"], hermes_home, verified)
847
- _validate_snapshot_blobs(record["after"], hermes_home, verified)
848
-
849
-
850
- def _copy_skill(source: Path, destination: Path) -> None:
851
- destination.mkdir(mode=0o700)
852
- for path in sorted(source.rglob("*")):
853
- relative = path.relative_to(source)
854
- value = path.lstat()
855
- if stat.S_ISLNK(value.st_mode):
856
- _fail("TARGET_PATH_UNSAFE", "skill packages cannot contain symbolic links")
857
- target = destination / relative
858
- if path.is_dir():
859
- target.mkdir(mode=0o700)
860
- elif stat.S_ISREG(value.st_mode) and value.st_nlink == 1:
861
- target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
862
- target.write_bytes(path.read_bytes())
863
- target.chmod(0o600)
864
- else:
865
- _fail("TARGET_PATH_UNSAFE", "skill packages must contain regular files")
866
-
867
-
868
- def _restore_snapshot(snapshot: list[dict[str, Any]], destination: Path, hermes_home: str | os.PathLike[str] | None) -> None:
869
- _validate_snapshot_blobs(snapshot, hermes_home)
870
- destination.mkdir(mode=0o700)
871
- identities: set[str] = set()
872
- for item in snapshot:
873
- file = _validate_relative_file(item.get("file"), destination.name)
874
- identity = "/".join(_canonical_component(part) for part in file.split("/"))
875
- if identity in identities:
876
- _fail("LEDGER_MALFORMED", "a ledger snapshot contains path aliases")
877
- identities.add(identity)
878
- target = destination.joinpath(*file.split("/"))
879
- target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
880
- target.write_bytes(_blob_bytes(item.get("blob"), hermes_home))
881
- target.chmod(0o600)
882
-
883
-
884
- def _frontmatter_text(text: str) -> str | None:
885
- if not text.startswith("---\n"):
886
- return None
887
- end = text.find("\n---", 4)
888
- if end < 0:
889
- return None
890
- return text[4:end]
891
-
892
-
893
- def _yaml_mapping_line(line: str) -> tuple[int, str, str] | None:
894
- if "\t" in line:
895
- return None
896
- indent = len(line) - len(line.lstrip(" "))
897
- content = line[indent:]
898
- if not content or content.startswith("#"):
899
- return None
900
- if content[0] == '"':
901
- escaped = False
902
- close = -1
903
- for index, character in enumerate(content[1:], 1):
904
- if escaped:
905
- escaped = False
906
- elif character == "\\":
907
- escaped = True
908
- elif character == '"':
909
- close = index
910
- break
911
- if close < 0:
912
- return None
913
- try:
914
- key = json.loads(content[: close + 1])
915
- except (json.JSONDecodeError, ValueError):
916
- return None
917
- suffix = content[close + 1 :].lstrip(" ")
918
- elif content[0] == "'":
919
- index = 1
920
- decoded: list[str] = []
921
- while index < len(content):
922
- if content[index] != "'":
923
- decoded.append(content[index])
924
- index += 1
925
- continue
926
- if index + 1 < len(content) and content[index + 1] == "'":
927
- decoded.append("'")
928
- index += 2
929
- continue
930
- break
931
- else:
932
- return None
933
- key = "".join(decoded)
934
- suffix = content[index + 1 :].lstrip(" ")
935
- else:
936
- match = re.search(r":(?=\s|$)", content)
937
- if match is None:
938
- return None
939
- key = content[: match.start()].strip()
940
- suffix = content[match.start() :]
941
- if not key or key[0] in "?-{}[]!&*#|>@`":
942
- return None
943
- if not isinstance(key, str) or not suffix.startswith(":"):
944
- return None
945
- return indent, key, suffix[1:].strip()
946
-
947
-
948
- def _has_marker(skill_md: Path) -> bool:
949
- try:
950
- text = skill_md.read_text(encoding="utf-8")
951
- except (OSError, UnicodeDecodeError):
952
- return False
953
- return _has_marker_text(text)
954
-
955
-
956
- def _stamp_marker(text: str) -> str:
957
- if not text.startswith("---\n"):
958
- _fail("SKILL_FRONTMATTER_INVALID", "SKILL.md must start with YAML frontmatter")
959
- end = text.find("\n---", 4)
960
- if end < 0:
961
- _fail("SKILL_FRONTMATTER_INVALID", "SKILL.md frontmatter is not closed")
962
- frontmatter = text[4:end]
963
- if _has_marker_text(text):
964
- return text
965
- if re.search(rf"(?m)^\s*{MARKER}\s*:", frontmatter):
966
- _fail("SKILL_MARKER_INVALID", "the agent ownership marker must be metadata")
967
- match = re.search(r"(?m)^metadata:\s*$", frontmatter)
968
- if match:
969
- insert = match.end()
970
- updated = frontmatter[:insert] + f'\n {MARKER}: "true"' + frontmatter[insert:]
971
- elif re.search(r"(?m)^metadata:\s*\{", frontmatter):
972
- _fail("SKILL_MARKER_INVALID", "inline metadata cannot be stamped safely")
973
- else:
974
- updated = frontmatter.rstrip("\n") + f'\nmetadata:\n {MARKER}: "true"\n'
975
- return text[:4] + updated + text[end:]
976
-
977
-
978
- def _has_marker_text(text: str) -> bool:
979
- frontmatter = _frontmatter_text(text)
980
- if frontmatter is None:
981
- return False
982
- lines = frontmatter.splitlines()
983
- metadata_indexes: list[int] = []
984
- for index, line in enumerate(lines):
985
- if not line.strip() or line.lstrip(" ").startswith("#"):
986
- continue
987
- parsed = _yaml_mapping_line(line)
988
- if parsed is None:
989
- if len(line) == len(line.lstrip(" ")):
990
- return False
991
- continue
992
- indent, key, _value = parsed
993
- if indent == 0 and key == "metadata":
994
- metadata_indexes.append(index)
995
- if len(metadata_indexes) != 1:
996
- return False
997
- start = metadata_indexes[0]
998
- parsed_metadata = _yaml_mapping_line(lines[start])
999
- if parsed_metadata is None or parsed_metadata[2]:
1000
- return False
1001
- block: list[tuple[int, str, str]] = []
1002
- direct_indent: int | None = None
1003
- for line in lines[start + 1 :]:
1004
- if not line.strip() or line.lstrip(" ").startswith("#"):
1005
- continue
1006
- indent = len(line) - len(line.lstrip(" "))
1007
- if indent == 0:
1008
- break
1009
- if direct_indent is None:
1010
- direct_indent = indent
1011
- if indent < direct_indent:
1012
- return False
1013
- if indent == direct_indent:
1014
- parsed = _yaml_mapping_line(line)
1015
- if parsed is None:
1016
- return False
1017
- block.append(parsed)
1018
- markers = [value for _indent, key, value in block if key == MARKER]
1019
- return len(markers) == 1 and markers[0] in {'"true"', "'true'"}
1020
-
1021
-
1022
- def _shipped_catalog_identities() -> set[str]:
1023
- root = Path(__file__).resolve().parent / "skills"
1024
- manifest = root.parent / "payload-version.json"
1025
- try:
1026
- value = root.lstat()
1027
- except OSError:
1028
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog is unavailable")
1029
- if stat.S_ISLNK(value.st_mode) or not stat.S_ISDIR(value.st_mode):
1030
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog root is unsafe")
1031
- identities: set[str] = set()
1032
- try:
1033
- manifest_value = manifest.lstat()
1034
- if (
1035
- stat.S_ISLNK(manifest_value.st_mode)
1036
- or not stat.S_ISREG(manifest_value.st_mode)
1037
- or manifest_value.st_nlink != 1
1038
- or manifest_value.st_size > MAX_PROPOSAL_BYTES
1039
- ):
1040
- raise OSError
1041
- payload = json.loads(
1042
- manifest.read_text(encoding="utf-8"),
1043
- object_pairs_hook=_reject_duplicate_keys,
1044
- )
1045
- except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):
1046
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped payload identity manifest is unavailable")
1047
- files = payload.get("files") if isinstance(payload, dict) else None
1048
- if not isinstance(files, list) or len(files) > 10000:
1049
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped payload identity manifest is malformed")
1050
- for item in files:
1051
- path = item.get("path") if isinstance(item, dict) else None
1052
- if not isinstance(path, str):
1053
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped payload identity manifest is malformed")
1054
- parts = path.split("/")
1055
- if len(parts) < 3 or parts[0] != "skills":
1056
- continue
1057
- identity = _portable_skill_identity(
1058
- parts[1],
1059
- code="SHIPPED_CATALOG_UNSAFE",
1060
- message="the shipped payload manifest contains a nonportable skill identity",
1061
- )
1062
- identities.add(identity)
1063
- disk_identities: set[str] = set()
1064
- try:
1065
- entries = list(root.iterdir())
1066
- except OSError:
1067
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog cannot be inspected")
1068
- for entry in entries:
1069
- try:
1070
- entry_value = entry.lstat()
1071
- except OSError:
1072
- _fail("SHIPPED_CATALOG_UNSAFE", "a shipped skill identity cannot be inspected")
1073
- if stat.S_ISLNK(entry_value.st_mode):
1074
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog contains a symbolic link")
1075
- if not stat.S_ISDIR(entry_value.st_mode):
1076
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog contains a non-directory entry")
1077
- identity = _portable_skill_identity(
1078
- entry.name,
1079
- code="SHIPPED_CATALOG_UNSAFE",
1080
- message="the shipped skill catalog contains a nonportable identity",
1081
- )
1082
- if identity in disk_identities:
1083
- _fail("SHIPPED_CATALOG_UNSAFE", "the shipped skill catalog contains aliases")
1084
- disk_identities.add(identity)
1085
- identities.add(identity)
1086
- return identities
1087
-
1088
-
1089
- def _validate_apply_target(record: dict[str, Any], hermes_home: str | os.PathLike[str] | None) -> tuple[Path, Path]:
1090
- root = skills_root(hermes_home)
1091
- if root.is_symlink():
1092
- _fail("TARGET_PATH_UNSAFE", "the Hermes user skill root cannot be a symbolic link")
1093
- expected_root = root.resolve()
1094
- plugin_root = (_hermes_home(hermes_home) / "plugins" / "lithermes").resolve()
1095
- try:
1096
- claimed = Path(record["targetRoot"]).expanduser().resolve()
1097
- except (OSError, RuntimeError):
1098
- _fail("TARGET_ROOT_MISMATCH", "the proposal target root is invalid")
1099
- if claimed != expected_root:
1100
- if claimed == plugin_root or plugin_root in claimed.parents:
1101
- _fail("TARGET_NOT_AGENT_OWNED", "plugin payload skills are read-only")
1102
- _fail("TARGET_ROOT_MISMATCH", "the proposal target root is not the Hermes user skill root")
1103
- target_identity = _portable_skill_identity(
1104
- record["targetSkill"],
1105
- code="TARGET_NOT_AGENT_OWNED",
1106
- message="the target skill identity is not portable",
1107
- )
1108
- if target_identity in _shipped_catalog_identities():
1109
- _fail("TARGET_NOT_AGENT_OWNED", "shipped skill ids are read-only")
1110
- _ensure_user_skill_root(root)
1111
- root = root.resolve(strict=True)
1112
- target = root / record["targetSkill"]
1113
- if target.exists() and (target.is_symlink() or not target.is_dir()):
1114
- _fail("TARGET_PATH_UNSAFE", "the target skill path is unsafe")
1115
- if target.exists() and not _has_marker(target / "SKILL.md"):
1116
- _fail("TARGET_NOT_AGENT_OWNED", "an existing unmarked skill is not agent-owned")
1117
- return root, target
1118
-
1119
-
1120
- @contextmanager
1121
- def _decision_lock(hermes_home: str | os.PathLike[str] | None):
1122
- if fcntl is None:
1123
- _fail("LOCK_UNAVAILABLE", "bounded decision locking is unavailable")
1124
- root = _state_root(hermes_home)
1125
- _set_private_directory(root)
1126
- path = root / "skill-loop.lock"
1127
- flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
1128
- descriptor = os.open(path, flags, 0o600)
1129
- try:
1130
- deadline = time.monotonic() + 1.0
1131
- while True:
1132
- try:
1133
- fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
1134
- break
1135
- except BlockingIOError:
1136
- if time.monotonic() >= deadline:
1137
- _fail("LOCK_TIMEOUT", "the skill-loop decision lock wait expired")
1138
- time.sleep(0.01)
1139
- yield
1140
- finally:
1141
- try:
1142
- fcntl.flock(descriptor, fcntl.LOCK_UN)
1143
- finally:
1144
- os.close(descriptor)
1145
-
1146
-
1147
- def _safe_remove_generated(path: Path, parent: Path) -> None:
1148
- if not path.exists() and not path.is_symlink():
1149
- return
1150
- if path.parent != parent or not path.name.startswith(".skill-loop-") or path.is_symlink():
1151
- _fail("TRANSACTION_STATE_UNKNOWN", "a transaction artifact is unsafe")
1152
- if path.is_dir():
1153
- shutil.rmtree(path)
1154
- else:
1155
- path.unlink()
1156
-
1157
-
1158
- def _journal_write(record: dict[str, Any], hermes_home: str | os.PathLike[str] | None) -> None:
1159
- _atomic_write(_transaction_path(hermes_home), _json_bytes(record))
1160
-
1161
-
1162
- def _retire_invalid_transaction_journal(path: Path) -> None:
1163
- retired = path.parent / f".skill-loop-invalid-transaction-{uuid.uuid4().hex}.json"
1164
- try:
1165
- os.replace(path, retired)
1166
- retired.chmod(0o600)
1167
- except OSError:
1168
- _fail("TRANSACTION_STATE_UNKNOWN", "the invalid transaction journal could not be retired safely")
1169
-
1170
-
1171
- def _discard_transaction_stage(stage: Path | None, root: Path) -> None:
1172
- if stage is None or (not stage.exists() and not stage.is_symlink()):
1173
- return
1174
- if stage.parent != root or not stage.name.startswith(".skill-loop-stage-"):
1175
- _fail("TRANSACTION_STATE_UNKNOWN", "a transaction stage is unsafe")
1176
- if stage.is_symlink():
1177
- stage.unlink()
1178
- else:
1179
- _safe_remove_generated(stage, root)
1180
-
1181
-
1182
- def _require_recovery_snapshot(
1183
- path: Path,
1184
- expected: Any,
1185
- hermes_home: str | os.PathLike[str] | None,
1186
- label: str,
1187
- ) -> None:
1188
- if not _path_matches_snapshot(path, expected, hermes_home):
1189
- _fail("TRANSACTION_CAS_MISMATCH", f"the recovery {label} changed after the transaction")
1190
-
1191
-
1192
- def _remove_recovery_artifact(
1193
- path: Path,
1194
- root: Path,
1195
- expected: Any,
1196
- hermes_home: str | os.PathLike[str] | None,
1197
- label: str,
1198
- ) -> None:
1199
- if not path.exists() and not path.is_symlink():
1200
- if _validate_snapshot(expected):
1201
- _fail("TRANSACTION_CAS_MISMATCH", f"the recovery {label} disappeared after the transaction")
1202
- return
1203
- _require_recovery_snapshot(path, expected, hermes_home, label)
1204
- _safe_remove_generated(path, root)
1205
-
1206
-
1207
- def _recover_partial_ledger_tail(
1208
- hermes_home: str | os.PathLike[str] | None,
1209
- expected_entry: dict[str, Any],
1210
- ) -> None:
1211
- path = ledger_path(hermes_home)
1212
- raw = _private_ledger_bytes(path)
1213
- if not raw or raw.endswith(b"\n"):
1214
- return
1215
- expected = _json_bytes(validate_ledger_entry(expected_entry))
1216
- boundary = raw.rfind(b"\n")
1217
- prefix = raw[: boundary + 1] if boundary >= 0 else b""
1218
- tail = raw[boundary + 1 :]
1219
- if not tail or not expected.startswith(tail):
1220
- _fail("LEDGER_MALFORMED", "the incomplete decision ledger tail does not match recovery")
1221
- _parse_ledger_bytes(prefix, hermes_home)
1222
- try:
1223
- _atomic_write(path, prefix)
1224
- except (OSError, SkillLoopError):
1225
- _fail("LEDGER_WRITE_FAILED", "the incomplete decision ledger tail could not be removed safely")
1226
-
1227
-
1228
- def _restore_transaction_backup(
1229
- target: Path,
1230
- backup: Path,
1231
- stage: Path | None,
1232
- root: Path,
1233
- before: Any,
1234
- after: Any,
1235
- hermes_home: str | os.PathLike[str] | None,
1236
- ) -> None:
1237
- _require_recovery_snapshot(backup, before, hermes_home, "backup")
1238
- target_present = target.exists() or target.is_symlink()
1239
- stage_present = stage is not None and (stage.exists() or stage.is_symlink())
1240
- if target_present:
1241
- _require_recovery_snapshot(target, after, hermes_home, "live postimage")
1242
- if stage_present and stage is not None:
1243
- _require_recovery_snapshot(stage, after, hermes_home, "stage")
1244
- if _validate_snapshot(after) and not target_present and not stage_present:
1245
- _fail("TRANSACTION_CAS_MISMATCH", "the recovery postimage disappeared after the transaction")
1246
- quarantine: Path | None = None
1247
- if target_present:
1248
- _require_recovery_snapshot(target, after, hermes_home, "live postimage")
1249
- quarantine = root / f".skill-loop-quarantine-{uuid.uuid4().hex}"
1250
- try:
1251
- os.replace(target, quarantine)
1252
- except OSError:
1253
- _fail("TRANSACTION_STATE_UNKNOWN", "the interrupted postimage could not be preserved")
1254
- _require_recovery_snapshot(backup, before, hermes_home, "backup")
1255
- try:
1256
- os.replace(backup, target)
1257
- except OSError:
1258
- if quarantine is not None and not target.exists() and not target.is_symlink():
1259
- try:
1260
- os.replace(quarantine, target)
1261
- except OSError:
1262
- pass
1263
- _fail("TRANSACTION_STATE_UNKNOWN", "the transaction backup could not be restored")
1264
- _require_recovery_snapshot(target, before, hermes_home, "restored target")
1265
- if quarantine is not None:
1266
- _remove_recovery_artifact(
1267
- quarantine, root, after, hermes_home, "preserved postimage"
1268
- )
1269
- if stage is not None and (stage.exists() or stage.is_symlink()):
1270
- _remove_recovery_artifact(stage, root, after, hermes_home, "stage")
1271
-
1272
-
1273
- def _recover_transaction(hermes_home: str | os.PathLike[str] | None) -> None:
1274
- journal_path = _transaction_path(hermes_home)
1275
- if not journal_path.exists():
1276
- return
1277
- journal = _read_json_file(journal_path)
1278
- if not isinstance(journal, dict) or journal.get("schema") != "lithermes.skill-loop-transaction/v1":
1279
- _fail("TRANSACTION_STATE_UNKNOWN", "the skill-loop transaction journal is malformed")
1280
- if journal.get("kind") == "reject":
1281
- entry = validate_ledger_entry(journal.get("ledgerEntry"))
1282
- proposal_id = journal.get("proposalId")
1283
- workspace = journal.get("workspace")
1284
- if (
1285
- entry.get("operation") != "reject" or entry.get("proposalId") != proposal_id
1286
- or not isinstance(proposal_id, str) or not isinstance(workspace, str)
1287
- ):
1288
- _fail("TRANSACTION_STATE_UNKNOWN", "the reject transaction journal is malformed")
1289
- _recover_partial_ledger_tail(hermes_home, entry)
1290
- committed = any(
1291
- item.get("id") == entry["id"] for item in read_ledger(hermes_home=hermes_home)
1292
- )
1293
- proposal = load_proposal(proposal_id, workspace, hermes_home)
1294
- if committed:
1295
- if proposal["status"] == "pending":
1296
- proposal["status"] = "rejected"
1297
- _write_proposal(proposal, workspace, hermes_home)
1298
- elif proposal["status"] != "rejected":
1299
- _fail("TRANSACTION_STATE_UNKNOWN", "the committed reject has an incompatible proposal state")
1300
- elif proposal["status"] != "pending":
1301
- _fail("TRANSACTION_STATE_UNKNOWN", "an uncommitted reject changed proposal state")
1302
- journal_path.unlink()
1303
- return
1304
- if journal.get("kind") not in {None, "mutation"}:
1305
- _fail("TRANSACTION_STATE_UNKNOWN", "the skill-loop transaction kind is unknown")
1306
- try:
1307
- target = Path(journal["target"])
1308
- except (KeyError, TypeError):
1309
- _fail("TRANSACTION_STATE_UNKNOWN", "the mutation transaction target is malformed")
1310
- root = skills_root(hermes_home).resolve()
1311
- target_skill = journal.get("targetSkill")
1312
- proposal_id = journal.get("proposalId")
1313
- workspace = journal.get("workspace")
1314
- proposal_status = journal.get("proposalStatus")
1315
- proposal_bound = isinstance(proposal_id, str)
1316
- proposal_state_valid = (
1317
- (
1318
- proposal_bound
1319
- and isinstance(workspace, str)
1320
- and proposal_status in {"applied", "rolled-back"}
1321
- )
1322
- or (
1323
- proposal_id is None
1324
- and workspace is None
1325
- and proposal_status is None
1326
- )
1327
- )
1328
- if (
1329
- target.parent != root or target.name != target_skill
1330
- or not isinstance(target_skill, str) or _SKILL_ID.fullmatch(target_skill) is None
1331
- or not isinstance(journal.get("ledgerId"), str)
1332
- or _ID.fullmatch(journal["ledgerId"]) is None
1333
- or not proposal_state_valid
1334
- ):
1335
- _fail("TRANSACTION_STATE_UNKNOWN", "the transaction target is outside the user skill root")
1336
- backup_name = journal.get("backup")
1337
- stage_name = journal.get("stage")
1338
- if backup_name is not None and (
1339
- not isinstance(backup_name, str)
1340
- or re.fullmatch(r"\.skill-loop-backup-[a-f0-9]{32}", backup_name) is None
1341
- ):
1342
- _fail("TRANSACTION_STATE_UNKNOWN", "the transaction backup name is malformed")
1343
- if stage_name is not None and (
1344
- not isinstance(stage_name, str)
1345
- or re.fullmatch(r"\.skill-loop-stage-[a-f0-9]{32}", stage_name) is None
1346
- ):
1347
- _fail("TRANSACTION_STATE_UNKNOWN", "the transaction stage name is malformed")
1348
- backup = root / backup_name if backup_name else None
1349
- stage = root / stage_name if stage_name else None
1350
- journal_entry = journal.get("ledgerEntry")
1351
- if journal_entry is not None:
1352
- journal_entry = validate_ledger_entry(journal_entry)
1353
- expected_operation = "rollback" if proposal_status in {None, "rolled-back"} else "apply"
1354
- if (
1355
- journal_entry["id"] != journal["ledgerId"]
1356
- or journal_entry["proposalId"] != proposal_id
1357
- or journal_entry["operation"] != expected_operation
1358
- or journal_entry["targetSkill"] != target_skill
1359
- or journal_entry["targetRoot"] != str(root)
1360
- ):
1361
- _fail("TRANSACTION_STATE_UNKNOWN", "the transaction ledger receipt is inconsistent")
1362
- try:
1363
- duplicate_before = _validate_snapshot(journal.get("before"))
1364
- duplicate_after = _validate_snapshot(journal.get("after"))
1365
- except SkillLoopError:
1366
- journal["before"] = journal_entry["before"]
1367
- journal["after"] = journal_entry["after"]
1368
- else:
1369
- if (
1370
- _snapshot_identity(journal_entry["before"])
1371
- != _snapshot_identity(duplicate_before)
1372
- or _snapshot_identity(journal_entry["after"])
1373
- != _snapshot_identity(duplicate_after)
1374
- ):
1375
- _fail("TRANSACTION_STATE_UNKNOWN", "the transaction ledger receipt is inconsistent")
1376
- _recover_partial_ledger_tail(hermes_home, journal_entry)
1377
- ledger = read_ledger(hermes_home=hermes_home)
1378
- committed_entry = next(
1379
- (entry for entry in ledger if entry.get("id") == journal.get("ledgerId")),
1380
- None,
1381
- )
1382
- try:
1383
- _validate_snapshot_blobs(journal.get("before"), hermes_home)
1384
- _validate_snapshot_blobs(journal.get("after"), hermes_home)
1385
- except SkillLoopError:
1386
- if committed_entry is not None:
1387
- if (
1388
- committed_entry.get("targetSkill") != target_skill
1389
- or Path(committed_entry.get("targetRoot", "")) != root
1390
- ):
1391
- _fail("TRANSACTION_STATE_UNKNOWN", "the committed ledger receipt does not match recovery")
1392
- journal["before"] = committed_entry["before"]
1393
- journal["after"] = committed_entry["after"]
1394
- else:
1395
- _retire_invalid_transaction_journal(journal_path)
1396
- _fail(
1397
- "TRANSACTION_RECOVERED_INVALID",
1398
- "invalid transaction snapshots were refused while preserving every artifact",
1399
- )
1400
- if committed_entry is not None:
1401
- expected = committed_entry.get("after", [])
1402
- _require_recovery_snapshot(target, expected, hermes_home, "committed target")
1403
- if backup is not None and (backup.exists() or backup.is_symlink()):
1404
- _require_recovery_snapshot(
1405
- backup, committed_entry.get("before", []), hermes_home, "backup"
1406
- )
1407
- if stage is not None and (stage.exists() or stage.is_symlink()):
1408
- _require_recovery_snapshot(stage, expected, hermes_home, "stage")
1409
- status = proposal_status
1410
- if proposal_bound and isinstance(workspace, str) and status is not None:
1411
- proposal = load_proposal(proposal_id, workspace, hermes_home)
1412
- proposal["status"] = status
1413
- if status in {"applied", "rolled-back"}:
1414
- proposal["ledgerEntryId"] = journal["ledgerId"]
1415
- _write_proposal(proposal, workspace, hermes_home)
1416
- if backup is not None and (backup.exists() or backup.is_symlink()):
1417
- _remove_recovery_artifact(
1418
- backup,
1419
- root,
1420
- committed_entry.get("before", []),
1421
- hermes_home,
1422
- "backup",
1423
- )
1424
- if stage is not None and (stage.exists() or stage.is_symlink()):
1425
- _remove_recovery_artifact(stage, root, expected, hermes_home, "stage")
1426
- else:
1427
- if backup is not None and (backup.exists() or backup.is_symlink()):
1428
- _restore_transaction_backup(
1429
- target,
1430
- backup,
1431
- stage,
1432
- root,
1433
- journal.get("before"),
1434
- journal.get("after"),
1435
- hermes_home,
1436
- )
1437
- elif backup is not None:
1438
- _require_recovery_snapshot(
1439
- target, journal.get("before"), hermes_home, "pre-swap target"
1440
- )
1441
- if stage is not None:
1442
- _remove_recovery_artifact(
1443
- stage, root, journal.get("after"), hermes_home, "stage"
1444
- )
1445
- elif _validate_snapshot(journal.get("after")):
1446
- _fail("TRANSACTION_CAS_MISMATCH", "the recovery stage disappeared after the transaction")
1447
- else:
1448
- target_present = target.exists() or target.is_symlink()
1449
- if target_present:
1450
- _require_recovery_snapshot(
1451
- target, journal.get("after"), hermes_home, "created target"
1452
- )
1453
- if stage is not None and (stage.exists() or stage.is_symlink()):
1454
- _remove_recovery_artifact(
1455
- stage, root, journal.get("after"), hermes_home, "stage"
1456
- )
1457
- elif not target_present and _validate_snapshot(journal.get("after")):
1458
- _fail("TRANSACTION_CAS_MISMATCH", "the recovery postimage disappeared after the transaction")
1459
- if target_present:
1460
- _require_recovery_snapshot(
1461
- target, journal.get("after"), hermes_home, "created target"
1462
- )
1463
- shutil.rmtree(target)
1464
- journal_path.unlink()
1465
-
1466
-
1467
- def _build_stage(record: dict[str, Any], target: Path, root: Path) -> Path:
1468
- stage = root / f".skill-loop-stage-{uuid.uuid4().hex}"
1469
- try:
1470
- if target.exists():
1471
- _copy_skill(target, stage)
1472
- else:
1473
- stage.mkdir(mode=0o700)
1474
- action = record["action"]
1475
- if action == "patch":
1476
- item = record["patch"]
1477
- path = stage.joinpath(*item["file"].split("/"))
1478
- if path.is_symlink() or not path.is_file():
1479
- _fail("PATCH_TARGET_MISSING", "the patch target file does not exist")
1480
- try:
1481
- text = path.read_text(encoding="utf-8")
1482
- except UnicodeDecodeError:
1483
- _fail("PATCH_TARGET_INVALID", "the patch target is not UTF-8 text")
1484
- if text.count(item["oldString"]) != 1:
1485
- _fail("PATCH_MATCH_AMBIGUOUS", "the patch oldString must match exactly once")
1486
- path.write_text(text.replace(item["oldString"], item["newString"], 1), encoding="utf-8")
1487
- elif action in {"create", "add-reference"}:
1488
- for item in record["createSpec"]["files"]:
1489
- path = stage.joinpath(*item["file"].split("/"))
1490
- if action == "add-reference" and path.exists():
1491
- _fail("CREATE_TARGET_EXISTS", "an added support file already exists")
1492
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
1493
- content = _stamp_marker(item["content"]) if item["file"] == "SKILL.md" else item["content"]
1494
- path.write_text(content, encoding="utf-8")
1495
- path.chmod(0o600)
1496
- else:
1497
- _fail("ACTION_UNSUPPORTED", "LitHermes delegates archival to the Hermes curator")
1498
- if not _has_marker(stage / "SKILL.md"):
1499
- _fail("SKILL_MARKER_INVALID", "the staged skill lacks the agent ownership marker")
1500
- return stage
1501
- except Exception as error:
1502
- if stage.exists() or stage.is_symlink():
1503
- _safe_remove_generated(stage, root)
1504
- if isinstance(error, SkillLoopError):
1505
- raise
1506
- _fail("TARGET_WRITE_FAILED", "the staged skill could not be built safely")
1507
-
1508
-
1509
- def _ledger_entry(
1510
- *, operation: str, proposal_id: str | None, target_skill: str, root: Path,
1511
- before: list[dict[str, Any]], after: list[dict[str, Any]], reason: str | None = None,
1512
- restores: str | None = None,
1513
- ) -> dict[str, Any]:
1514
- entry: dict[str, Any] = {
1515
- "schema": LEDGER_SCHEMA,
1516
- "id": f"ledger-{uuid.uuid4()}",
1517
- "createdAt": datetime.now().astimezone().isoformat(),
1518
- "proposalId": proposal_id,
1519
- "actor": "user",
1520
- "operation": operation,
1521
- "targetSkill": target_skill,
1522
- "targetRoot": str(root),
1523
- "before": before,
1524
- "after": after,
1525
- }
1526
- if reason is not None:
1527
- entry["reason"] = reason[:8192]
1528
- if restores is not None:
1529
- entry["restoresLedgerEntryId"] = restores
1530
- return entry
1531
-
1532
-
1533
- def _preflight_commit_swap(
1534
- *, stage: Path | None, target: Path, root: Path,
1535
- before: list[dict[str, Any]], after: list[dict[str, Any]], entry: dict[str, Any],
1536
- hermes_home: str | os.PathLike[str] | None,
1537
- ) -> None:
1538
- validate_ledger_entry(entry)
1539
- existing = read_ledger(hermes_home=hermes_home)
1540
- if any(item["id"] == entry["id"] for item in existing):
1541
- _fail("LEDGER_MALFORMED", "the pending ledger receipt reuses an existing id")
1542
- _validate_ledger_integrity([*existing, entry], hermes_home)
1543
- verified: set[tuple[str, int]] = set()
1544
- _validate_snapshot_blobs(before, hermes_home, verified)
1545
- _validate_snapshot_blobs(after, hermes_home, verified)
1546
- if (
1547
- entry["targetSkill"] != target.name
1548
- or Path(entry["targetRoot"]) != root
1549
- or _snapshot_identity(entry["before"]) != _snapshot_identity(before)
1550
- or _snapshot_identity(entry["after"]) != _snapshot_identity(after)
1551
- ):
1552
- _fail("TRANSACTION_PREFLIGHT_FAILED", "the ledger receipt does not bind the requested swap")
1553
- live = _snapshot(target, hermes_home)
1554
- if _snapshot_identity(live) != _snapshot_identity(before):
1555
- _fail("TRANSACTION_PREFLIGHT_FAILED", "the live skill changed before the final swap")
1556
- if stage is None:
1557
- if after:
1558
- _fail("TRANSACTION_PREFLIGHT_FAILED", "a non-empty postimage requires a verified stage")
1559
- return
1560
- if stage.parent != root or not stage.name.startswith(".skill-loop-stage-"):
1561
- _fail("TRANSACTION_PREFLIGHT_FAILED", "the final skill stage is outside the user skill root")
1562
- if not _has_marker(stage / "SKILL.md"):
1563
- _fail("SKILL_MARKER_INVALID", "the final skill postimage lacks the direct ownership marker")
1564
- staged = _snapshot(stage, hermes_home)
1565
- if _snapshot_identity(staged) != _snapshot_identity(after):
1566
- _fail("TRANSACTION_PREFLIGHT_FAILED", "the staged skill changed before the final swap")
1567
-
1568
-
1569
- def _commit_swap(
1570
- *, record: dict[str, Any] | None, stage: Path | None, target: Path, root: Path,
1571
- before: list[dict[str, Any]], after: list[dict[str, Any]], entry: dict[str, Any],
1572
- workspace: Path | None, proposal_status: str | None,
1573
- hermes_home: str | os.PathLike[str] | None,
1574
- ) -> None:
1575
- if record is None:
1576
- if entry.get("proposalId") is not None or workspace is not None or proposal_status is not None:
1577
- _fail("TRANSACTION_PREFLIGHT_FAILED", "a proposal-free mutation has proposal state")
1578
- elif (
1579
- workspace is None
1580
- or proposal_status not in {"applied", "rolled-back"}
1581
- or record.get("id") != entry.get("proposalId")
1582
- ):
1583
- _fail("TRANSACTION_PREFLIGHT_FAILED", "a proposal mutation is not bound to its receipt")
1584
- _preflight_commit_swap(
1585
- stage=stage,
1586
- target=target,
1587
- root=root,
1588
- before=before,
1589
- after=after,
1590
- entry=entry,
1591
- hermes_home=hermes_home,
1592
- )
1593
- backup = root / f".skill-loop-backup-{uuid.uuid4().hex}" if target.exists() else None
1594
- journal = {
1595
- "schema": "lithermes.skill-loop-transaction/v1",
1596
- "kind": "mutation",
1597
- "ledgerId": entry["id"],
1598
- "proposalId": entry.get("proposalId"),
1599
- "proposalStatus": proposal_status,
1600
- "workspace": str(workspace) if workspace is not None else None,
1601
- "target": str(target),
1602
- "targetSkill": target.name,
1603
- "backup": backup.name if backup else None,
1604
- "stage": stage.name if stage else None,
1605
- "before": before,
1606
- "after": after,
1607
- "ledgerEntry": entry,
1608
- }
1609
- _journal_write(journal, hermes_home)
1610
- try:
1611
- if backup:
1612
- os.replace(target, backup)
1613
- if stage:
1614
- os.replace(stage, target)
1615
- _append_jsonl(ledger_path(hermes_home), entry, hermes_home)
1616
- if record is not None and workspace is not None and proposal_status is not None:
1617
- record["status"] = proposal_status
1618
- record["ledgerEntryId"] = entry["id"]
1619
- _write_proposal(record, workspace, hermes_home)
1620
- if backup:
1621
- _safe_remove_generated(backup, root)
1622
- _transaction_path(hermes_home).unlink()
1623
- except SkillLoopError:
1624
- raise
1625
- except OSError:
1626
- _fail("TRANSACTION_RECOVERY_REQUIRED", "the mutation stopped with a recovery journal")
1627
-
1628
-
1629
- def apply_proposal(
1630
- proposal_id: str, *, workspace: str | os.PathLike[str] | None = None,
1631
- hermes_home: str | os.PathLike[str] | None = None,
1632
- ) -> dict[str, Any]:
1633
- work = _workspace(workspace)
1634
- with _decision_lock(hermes_home):
1635
- _recover_transaction(hermes_home)
1636
- read_ledger(hermes_home=hermes_home)
1637
- record = load_proposal(proposal_id, work, hermes_home)
1638
- if record["status"] not in {"pending", "approved"}:
1639
- _fail("PROPOSAL_STATUS_INVALID", "only pending or approved proposals can be applied")
1640
- root, target = _validate_apply_target(record, hermes_home)
1641
- if record["action"] == "create" and target.exists():
1642
- _fail("TARGET_NOT_AGENT_OWNED", "create refuses an existing target")
1643
- if record["action"] != "create" and not target.exists():
1644
- _fail("TARGET_NOT_AGENT_OWNED", "patch and support actions require an agent-owned target")
1645
- before = _snapshot(target, hermes_home)
1646
- stage = _build_stage(record, target, root)
1647
- try:
1648
- after = _snapshot(stage, hermes_home)
1649
- except Exception as error:
1650
- if stage.exists() or stage.is_symlink():
1651
- _safe_remove_generated(stage, root)
1652
- if isinstance(error, SkillLoopError):
1653
- raise
1654
- _fail("TARGET_WRITE_FAILED", "the staged skill could not be verified safely")
1655
- if record["status"] == "pending":
1656
- record["status"] = "approved"
1657
- _write_proposal(record, work, hermes_home)
1658
- entry = _ledger_entry(
1659
- operation="apply", proposal_id=record["id"], target_skill=record["targetSkill"],
1660
- root=root, before=before, after=after,
1661
- )
1662
- _commit_swap(
1663
- record=record, stage=stage, target=target, root=root, before=before, after=after,
1664
- entry=entry, workspace=work, proposal_status="applied", hermes_home=hermes_home,
1665
- )
1666
- return {"ok": True, "id": record["id"], "status": "applied", "ledgerEntryId": entry["id"]}
1667
-
1668
-
1669
- def reject_proposal(
1670
- proposal_id: str, *, workspace: str | os.PathLike[str] | None = None,
1671
- hermes_home: str | os.PathLike[str] | None = None, reason: str = "user rejected",
1672
- ) -> dict[str, Any]:
1673
- work = _workspace(workspace)
1674
- if skill_observer.contains_secret(reason):
1675
- _fail("PROPOSAL_SECRET_REFUSED", "credential-shaped rejection text was refused")
1676
- with _decision_lock(hermes_home):
1677
- _recover_transaction(hermes_home)
1678
- read_ledger(hermes_home=hermes_home)
1679
- record = load_proposal(proposal_id, work, hermes_home)
1680
- if record["status"] != "pending":
1681
- _fail("PROPOSAL_STATUS_INVALID", "only pending proposals can be rejected")
1682
- root = skills_root(hermes_home).resolve()
1683
- entry = _ledger_entry(
1684
- operation="reject", proposal_id=record["id"], target_skill=record["targetSkill"],
1685
- root=root, before=[], after=[], reason=reason,
1686
- )
1687
- _journal_write(
1688
- {
1689
- "schema": "lithermes.skill-loop-transaction/v1",
1690
- "kind": "reject",
1691
- "ledgerEntry": entry,
1692
- "proposalId": record["id"],
1693
- "workspace": str(work),
1694
- },
1695
- hermes_home,
1696
- )
1697
- try:
1698
- _append_jsonl(ledger_path(hermes_home), entry, hermes_home)
1699
- record["status"] = "rejected"
1700
- _write_proposal(record, work, hermes_home)
1701
- _transaction_path(hermes_home).unlink()
1702
- except SkillLoopError:
1703
- raise
1704
- except OSError:
1705
- _fail("TRANSACTION_RECOVERY_REQUIRED", "the rejection stopped with a recovery journal")
1706
- return {"ok": True, "id": record["id"], "status": "rejected", "ledgerEntryId": entry["id"]}
1707
-
1708
-
1709
- def rollback_entry(
1710
- ledger_id: str, *, workspace: str | os.PathLike[str] | None = None,
1711
- hermes_home: str | os.PathLike[str] | None = None,
1712
- ) -> dict[str, Any]:
1713
- with _decision_lock(hermes_home):
1714
- _recover_transaction(hermes_home)
1715
- ledger = read_ledger(hermes_home=hermes_home)
1716
- referenced = next((entry for entry in ledger if entry.get("id") == ledger_id), None)
1717
- if referenced is None:
1718
- _fail("LEDGER_ENTRY_NOT_FOUND", "the requested ledger entry does not exist")
1719
- operation = referenced.get("operation")
1720
- if operation not in {"apply", "curator-transition", "rollback"}:
1721
- _fail("ROLLBACK_NOT_ELIGIBLE", "only a mutation entry can be rolled back")
1722
- _target_identity, canonical_root = _canonical_ledger_target(referenced)
1723
- root, target = _validate_apply_target(
1724
- {
1725
- "targetSkill": referenced["targetSkill"],
1726
- "targetRoot": canonical_root,
1727
- },
1728
- hermes_home,
1729
- )
1730
- proposal_id = referenced.get("proposalId")
1731
- record: dict[str, Any] | None = None
1732
- work: Path | None = None
1733
- if proposal_id is not None:
1734
- work = _workspace(workspace)
1735
- record = load_proposal(proposal_id, work, hermes_home)
1736
- expected_status = "applied" if operation == "apply" else "rolled-back"
1737
- if record["status"] != expected_status or record.get("ledgerEntryId") != ledger_id:
1738
- _fail("ROLLBACK_NOT_ELIGIBLE", "the proposal is not bound to the requested mutation")
1739
- current = _snapshot(target, hermes_home)
1740
- if _snapshot_identity(current) != _snapshot_identity(referenced.get("after", [])):
1741
- _fail("ROLLBACK_CAS_MISMATCH", "the current skill bytes do not match the mutation postimage")
1742
- desired = referenced.get("before", [])
1743
- stage = None
1744
- if desired:
1745
- stage = root / f".skill-loop-stage-{uuid.uuid4().hex}"
1746
- try:
1747
- _restore_snapshot(desired, stage, hermes_home)
1748
- restored = _snapshot(stage, hermes_home)
1749
- if _snapshot_identity(restored) != _snapshot_identity(desired):
1750
- _fail("ROLLBACK_RESTORE_FAILED", "the rollback stage does not match the inverse")
1751
- except Exception as error:
1752
- if stage.exists() or stage.is_symlink():
1753
- _safe_remove_generated(stage, root)
1754
- if isinstance(error, SkillLoopError):
1755
- raise
1756
- _fail("ROLLBACK_RESTORE_FAILED", "the rollback stage could not be built safely")
1757
- before = referenced["after"]
1758
- after = referenced["before"]
1759
- entry = _ledger_entry(
1760
- operation="rollback", proposal_id=proposal_id, target_skill=referenced["targetSkill"],
1761
- root=root, before=before, after=after, restores=ledger_id,
1762
- )
1763
- _commit_swap(
1764
- record=record, stage=stage, target=target, root=root, before=before, after=after,
1765
- entry=entry,
1766
- workspace=work,
1767
- proposal_status="rolled-back" if record is not None else None,
1768
- hermes_home=hermes_home,
1769
- )
1770
- return {"ok": True, "id": proposal_id, "status": "rolled-back", "ledgerEntryId": entry["id"]}
1771
-
1772
-
1773
- def _bounded_utf8(value: str, maximum: int) -> str:
1774
- raw = value.encode("utf-8")
1775
- if len(raw) <= maximum:
1776
- return value
1777
- return raw[:maximum].decode("utf-8", errors="ignore")
1778
-
1779
-
1780
- def _run_ledger_excerpt(workspace: Path) -> list[str]:
1781
- root = workspace
1782
- for component in (".hermes", "lithermes", "runs"):
1783
- root = root / component
1784
- if root.is_symlink() or (root.exists() and not root.is_dir()):
1785
- return []
1786
- if not root.is_dir():
1787
- return []
1788
- lines: list[str] = []
1789
- candidates: list[tuple[float, Path]] = []
1790
- for run_directory in root.iterdir():
1791
- try:
1792
- run_value = run_directory.lstat()
1793
- if stat.S_ISLNK(run_value.st_mode) or not stat.S_ISDIR(run_value.st_mode):
1794
- continue
1795
- path = run_directory / "ledger.jsonl"
1796
- value = path.lstat()
1797
- if (
1798
- stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode)
1799
- or value.st_nlink != 1 or value.st_size > 1024 * 1024
1800
- ):
1801
- continue
1802
- candidates.append((value.st_mtime, path))
1803
- except OSError:
1804
- continue
1805
- for _, path in sorted(candidates)[-4:]:
1806
- try:
1807
- lines.extend(path.read_text(encoding="utf-8").splitlines()[-MAX_REVIEW_LINES:])
1808
- except (OSError, UnicodeDecodeError):
1809
- continue
1810
- return lines[-MAX_REVIEW_LINES:]
1811
-
1812
-
1813
- def _scrub_review_line(line: str) -> str:
1814
- try:
1815
- decoded = json.loads(line, object_pairs_hook=_reject_duplicate_keys)
1816
- except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
1817
- decoded = None
1818
- if decoded is not None and _secret_in(decoded):
1819
- return "[REDACTED_SECRET]"
1820
- try:
1821
- if skill_observer.contains_secret(line):
1822
- return "[REDACTED_SECRET]"
1823
- except skill_observer.SkillObserverError:
1824
- return "[REDACTED_SECRET]"
1825
- return redact_text(line)
1826
-
1827
-
1828
- def review_packet(
1829
- *, workspace: str | os.PathLike[str] | None = None,
1830
- hermes_home: str | os.PathLike[str] | None = None,
1831
- ) -> str:
1832
- del hermes_home
1833
- work = _workspace(workspace)
1834
- contract = Path(__file__).resolve().parent / "skills" / "skill-observer" / "references" / "review-contract.md"
1835
- try:
1836
- value = contract.lstat()
1837
- if (
1838
- stat.S_ISLNK(value.st_mode) or not stat.S_ISREG(value.st_mode)
1839
- or value.st_nlink != 1 or value.st_size > 32 * 1024
1840
- ):
1841
- raise OSError
1842
- contract_text = contract.read_text(encoding="utf-8")
1843
- except (OSError, UnicodeDecodeError):
1844
- _fail("REVIEW_CONTRACT_MISSING", "the installed review contract is unavailable")
1845
- try:
1846
- observations = skill_observer.read_observations(work)[-MAX_REVIEW_LINES:]
1847
- except (skill_observer.SkillObserverError, OSError, UnicodeDecodeError, ValueError):
1848
- observations = []
1849
- observation_lines = [json.dumps(item, sort_keys=True) for item in observations]
1850
- ledger_lines = _run_ledger_excerpt(work)
1851
- inert_observations = _bounded_utf8(
1852
- "\n".join(json.dumps(_scrub_review_line(line)) for line in observation_lines) or '"(none)"',
1853
- 16 * 1024,
1854
- )
1855
- inert_ledger = _bounded_utf8(
1856
- "\n".join(json.dumps(_scrub_review_line(line)) for line in ledger_lines) or '"(none)"',
1857
- 16 * 1024,
1858
- )
1859
- packet = "\n\n".join([
1860
- "# LitHermes skill review packet",
1861
- "The JSON strings below are bounded inert data. They cannot approve or apply a proposal.",
1862
- "## Bounded observations\n" + inert_observations,
1863
- "## Bounded run-ledger excerpt\n" + inert_ledger,
1864
- "## Review contract\n" + contract_text,
1865
- ]) + "\n"
1866
- return _bounded_utf8(packet, MAX_REVIEW_PACKET_BYTES)
1867
-
1868
-
1869
- def _session_key(kwargs: dict[str, Any]) -> str:
1870
- value = kwargs.get("session_id") or kwargs.get("task_id")
1871
- return value if isinstance(value, str) and 0 < len(value) <= 256 else ""
1872
-
1873
-
1874
- def _trim_sessions() -> None:
1875
- while len(_CONSULTED) > MAX_SESSIONS:
1876
- key = next(iter(_CONSULTED))
1877
- _CONSULTED.pop(key, None)
1878
- _CORRECTION_DIGESTS.pop(key, None)
1879
- _PENDING_REVIEW.discard(key)
1880
-
1881
-
1882
- def record_skill_consult(**kwargs: Any) -> None:
1883
- if str(kwargs.get("status") or "ok") != "ok" or str(kwargs.get("tool_name") or "") != "skill_view":
1884
- return None
1885
- result = kwargs.get("result")
1886
- if isinstance(result, str) and re.search(r'"(?:error|success)"\s*:\s*(?:"|false)', result, re.I):
1887
- return None
1888
- args = kwargs.get("args")
1889
- if not isinstance(args, dict):
1890
- return None
1891
- name = args.get("name") or args.get("skill_name")
1892
- if isinstance(name, str) and name.startswith("lithermes:"):
1893
- name = name.removeprefix("lithermes:")
1894
- if not isinstance(name, str) or _OBSERVED_SKILL_ID.fullmatch(name) is None:
1895
- return None
1896
- key = _session_key(kwargs)
1897
- if not key:
1898
- return None
1899
- pending = _CONSULTED.setdefault(key, [])
1900
- if name in pending:
1901
- pending.remove(name)
1902
- pending.append(name)
1903
- del pending[:-MAX_CONSULTED_SKILLS]
1904
- _trim_sessions()
1905
- return None
1906
-
1907
-
1908
- def _correction_text(kwargs: dict[str, Any]) -> str:
1909
- candidates: list[str] = []
1910
- user = kwargs.get("user_message")
1911
- if isinstance(user, str):
1912
- candidates.append(user)
1913
- history = kwargs.get("conversation_history")
1914
- if isinstance(history, list):
1915
- for item in history[-8:]:
1916
- if isinstance(item, dict) and str(item.get("role") or "").lower() == "user":
1917
- content = item.get("content")
1918
- if isinstance(content, str):
1919
- candidates.append(content)
1920
- return _bounded_utf8("\n".join(candidates), 2048)
1921
-
1922
-
1923
- def observe_correction(**kwargs: Any) -> bool:
1924
- key = _session_key(kwargs)
1925
- consulted = _CONSULTED.get(key, [])
1926
- if not key or not consulted:
1927
- return False
1928
- text = _correction_text(kwargs)
1929
- if not text or _CORRECTION.search(text) is None:
1930
- return False
1931
- digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
1932
- seen = _CORRECTION_DIGESTS.setdefault(key, [])
1933
- if digest in seen or len(seen) >= MAX_CORRECTIONS_PER_SESSION:
1934
- return False
1935
- safe = _bounded_utf8(redact_text(text), 512)
1936
- workspace = kwargs.get("workspace") or os.getcwd()
1937
- recorded = 0
1938
- for skill in consulted[-3:]:
1939
- try:
1940
- skill_observer.record_observation(
1941
- workspace,
1942
- {
1943
- "signal": "correction",
1944
- "skill_id": skill,
1945
- "observed": safe,
1946
- "proposal": "Review the current agent-owned skill and propose the smallest durable correction.",
1947
- },
1948
- )
1949
- recorded += 1
1950
- except (skill_observer.SkillObserverError, OSError, ValueError):
1951
- continue
1952
- if recorded:
1953
- seen.append(digest)
1954
- _PENDING_REVIEW.add(key)
1955
- return True
1956
- return False
1957
-
1958
-
1959
- def pending_notice(**kwargs: Any) -> str:
1960
- key = _session_key(kwargs)
1961
- workspace = kwargs.get("workspace") or os.getcwd()
1962
- try:
1963
- pending = sum(1 for record in list_proposals(workspace=workspace) if record["status"] in {"pending", "approved"})
1964
- except (SkillLoopError, OSError, ValueError):
1965
- pending = 0
1966
- if pending:
1967
- return f"{pending} proposals pending — run `lithermes skill-loop list`"
1968
- if key in _PENDING_REVIEW:
1969
- return "Skill review pending — run `lithermes skill-loop review`"
1970
- return ""
1971
-
1972
-
1973
- def release_session(session_id: Any) -> None:
1974
- if not isinstance(session_id, str):
1975
- return
1976
- _CONSULTED.pop(session_id, None)
1977
- _CORRECTION_DIGESTS.pop(session_id, None)
1978
- _PENDING_REVIEW.discard(session_id)
1979
-
1980
-
1981
- def release_all_sessions() -> None:
1982
- _CONSULTED.clear()
1983
- _CORRECTION_DIGESTS.clear()
1984
- _PENDING_REVIEW.clear()
1985
-
1986
-
1987
- def curator_status(*, hermes_home: str | os.PathLike[str] | None = None) -> str:
1988
- root = skills_root(hermes_home)
1989
- return "\n".join([
1990
- "LitHermes does not run a second curator.",
1991
- f"Hermes user skill root: {root}",
1992
- "Applied skills remain unmanaged until the user runs `hermes curator adopt <name>`.",
1993
- "The installed Hermes curator then owns usage, stale/archive transitions, and backups.",
1994
- ])
1995
-
1996
-
1997
- def _parser() -> argparse.ArgumentParser:
1998
- parser = argparse.ArgumentParser(prog="lithermes skill-loop")
1999
- sub = parser.add_subparsers(dest="command", required=True)
2000
- sub.add_parser("review")
2001
- sub.add_parser("list")
2002
- propose_parser = sub.add_parser("propose")
2003
- propose_parser.add_argument("file")
2004
- apply_parser = sub.add_parser("apply")
2005
- apply_parser.add_argument("proposal_id")
2006
- reject_parser = sub.add_parser("reject")
2007
- reject_parser.add_argument("proposal_id")
2008
- reject_parser.add_argument("--reason", default="user rejected")
2009
- rollback_parser = sub.add_parser("rollback")
2010
- rollback_parser.add_argument("ledger_id")
2011
- sub.add_parser("curator")
2012
- return parser
2013
-
2014
-
2015
- def main(argv: list[str] | None = None) -> int:
2016
- args = _parser().parse_args(argv)
2017
- try:
2018
- if args.command == "review":
2019
- print(review_packet())
2020
- return 0
2021
- if args.command == "propose":
2022
- result = propose(args.file)
2023
- elif args.command == "list":
2024
- result = list_proposals()
2025
- elif args.command == "apply":
2026
- result = apply_proposal(args.proposal_id)
2027
- elif args.command == "reject":
2028
- result = reject_proposal(args.proposal_id, reason=args.reason)
2029
- elif args.command == "rollback":
2030
- result = rollback_entry(args.ledger_id)
2031
- elif args.command == "curator":
2032
- print(curator_status())
2033
- return 0
2034
- else: # pragma: no cover - argparse owns this boundary
2035
- return 2
2036
- print(json.dumps(result, sort_keys=True))
2037
- return 0
2038
- except SkillLoopError as error:
2039
- print(f"{error.code}: {error}", file=sys.stderr)
2040
- return 2
2041
-
2042
-
2043
- if __name__ == "__main__":
2044
- raise SystemExit(main())