1717import re
1818import socket
1919import sys
20- import tempfile
2120import time
22- import uuid
2321from datetime import datetime , timedelta , timezone
2422from pathlib import Path
2523from typing import Any , Dict , Iterable , List , Mapping , Optional , Sequence , Set , Tuple
2624
25+ # Prefer the installed package; fall back to the pinned vendored copy. The hook
26+ # runs before anything is installed, so the fallback is what makes drift detection
27+ # work on a bare install. The copy is generated by scripts/sync_vendored_core.py
28+ # and CI fails if it disagrees with the package.
29+ try :
30+ import agentrust_capture_core as core
31+ except ImportError : # pragma: no cover - exercised by the bare-install path
32+ sys .path .insert (0 , str (Path (__file__ ).resolve ().parent / "_vendor" ))
33+ import agentrust_capture_core as core
34+
2735
2836VERSION = "0.1.0"
2937
6068}
6169
6270
63- def _sha_bytes (value : bytes ) -> str :
64- return "sha256:" + hashlib .sha256 (value ).hexdigest ()
65-
66-
67- def _sha_file (path : Path ) -> str :
68- return _sha_bytes (path .read_bytes ())
69-
70-
71- def _sha_mapping (value : Mapping [str , Any ]) -> str :
72- encoded = json .dumps (value , sort_keys = True , separators = ("," , ":" )).encode ("utf-8" )
73- return _sha_bytes (encoded )
71+ _sha_bytes = core .sha_bytes
72+ _sha_file = core .sha_file
73+ _sha_mapping = core .sha_mapping
74+ _now_iso = core .now_iso
75+ _uuid7 = core .uuid7
7476
7577
7678def _safe_hash (path : Path ) -> Optional [str ]:
79+ """Digest a regular file, or None if it is missing, unreadable, or a symlink.
80+
81+ Stricter than core.safe_sha_file, which does not consider symlinks. Kept
82+ stricter here on purpose: Codex resolves instructions and policy from
83+ per-workspace paths, so a cloned repo could point a symlink at a file outside
84+ the tree and have its contents recorded as if they belonged to the workspace.
85+ """
7786 try :
7887 if path .is_file () and not path .is_symlink ():
7988 return _sha_file (path )
@@ -82,20 +91,6 @@ def _safe_hash(path: Path) -> Optional[str]:
8291 return None
8392
8493
85- def _now_iso () -> str :
86- return (
87- datetime .now (timezone .utc ).isoformat (timespec = "seconds" ).replace ("+00:00" , "Z" )
88- )
89-
90-
91- def _uuid7 () -> str :
92- milliseconds = int (time .time () * 1000 )
93- raw = bytearray (milliseconds .to_bytes (6 , "big" ) + os .urandom (10 ))
94- raw [6 ] = 0x70 | (raw [6 ] & 0x0F )
95- raw [8 ] = 0x80 | (raw [8 ] & 0x3F )
96- return str (uuid .UUID (bytes = bytes (raw )))
97-
98-
9994def _slug (value : str ) -> str :
10095 normalized = re .sub (r"[^a-z0-9._-]+" , "-" , value .lower ()).strip ("-" )
10196 return normalized or "unknown"
@@ -217,60 +212,16 @@ def _skill_roots(chain: Sequence[Path]) -> List[Tuple[str, Path]]:
217212#: Controlled here rather than by a file inside the skill on purpose. A per-skill
218213#: ignore file would let the thing being measured decide what gets measured, so a
219214#: hostile skill could ship an ignore rule covering its own payload.
220- SKILL_EXCLUDE_DIRS = frozenset (
221- {"state" , ".cache" , "__pycache__" , ".git" , ".pytest_cache" , "node_modules" }
222- )
215+ SKILL_EXCLUDE_DIRS = core .EXCLUDE_DIRS
223216
224217#: File suffixes skipped for the same reason: run artifacts, not behaviour.
225- SKILL_EXCLUDE_SUFFIXES = frozenset ({ ".log" , ".tmp" , ".pyc" , ".pyo" })
218+ SKILL_EXCLUDE_SUFFIXES = core . EXCLUDE_SUFFIXES
226219
227220
228- def _skill_tree_digest (skill_dir : Path ) -> Optional [str ]:
229- """Hash every behavioural file in one skill directory.
230-
231- Covers the whole tree rather than SKILL.md alone. A skill is not just its
232- manifest: these directories carry scripts, tools and reference material that
233- decide what the skill actually does, so hashing only SKILL.md let a payload be
234- swapped into scripts/ while the report said nothing added, nothing subtracted.
235-
236- Relative paths are bound into the digest alongside contents so a rename or a
237- move is drift too, and symlinks are skipped so a link out of the tree cannot
238- drag unrelated content into the fingerprint.
239- """
240- digest = hashlib .sha256 ()
241- try :
242- paths = sorted (skill_dir .rglob ("*" ))
243- except OSError :
244- return None
245- seen_any = False
246- for path in paths :
247- if path .is_symlink () or not path .is_file ():
248- continue
249- try :
250- relative = path .relative_to (skill_dir )
251- except ValueError : # pragma: no cover - rglob results are relative
252- continue
253- if SKILL_EXCLUDE_DIRS & set (relative .parts [:- 1 ]):
254- continue
255- if path .suffix in SKILL_EXCLUDE_SUFFIXES :
256- continue
257- try :
258- content = path .read_bytes ()
259- except OSError :
260- # An unreadable file is itself worth recording: bind its path so the
261- # file appearing or vanishing still moves the digest.
262- digest .update (relative .as_posix ().encode ("utf-8" ))
263- digest .update (b"\0 <unreadable>\0 " )
264- seen_any = True
265- continue
266- digest .update (relative .as_posix ().encode ("utf-8" ))
267- digest .update (b"\0 " )
268- digest .update (content )
269- digest .update (b"\0 " )
270- seen_any = True
271- if not seen_any :
272- return None
273- return "sha256:" + digest .hexdigest ()
221+ #: Digest every behavioural file in one skill directory, or None when nothing
222+ #: readable was found. See core.tree_digest for why the whole tree is covered
223+ #: rather than SKILL.md alone, and for the symlink and exclusion behaviour.
224+ _skill_tree_digest = core .tree_digest
274225
275226
276227def _skill_fingerprints (chain : Sequence [Path ]) -> Dict [str , str ]:
@@ -593,57 +544,24 @@ def snapshot(live: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
593544 }
594545
595546
596- def _map_changes (
597- before : Mapping [str , str ],
598- after : Mapping [str , str ],
599- label : str ,
600- ) -> List [Dict [str , str ]]:
601- changes : List [Dict [str , str ]] = []
602- before_keys , after_keys = set (before ), set (after )
603- for name in sorted (after_keys - before_keys ):
604- changes .append ({"change" : "added" , "what" : label , "detail" : name })
605- for name in sorted (before_keys - after_keys ):
606- changes .append ({"change" : "removed" , "what" : label , "detail" : name })
607- for name in sorted (before_keys & after_keys ):
608- if before [name ] != after [name ]:
609- changes .append ({"change" : "changed" , "what" : label , "detail" : name })
610- return changes
611-
612-
613- def _set_changes (
614- before : Iterable [str ],
615- after : Iterable [str ],
616- label : str ,
617- ) -> List [Dict [str , str ]]:
618- changes : List [Dict [str , str ]] = []
619- before_set , after_set = set (before ), set (after )
620- for name in sorted (after_set - before_set ):
621- changes .append ({"change" : "added" , "what" : label , "detail" : name })
622- for name in sorted (before_set - after_set ):
623- changes .append ({"change" : "removed" , "what" : label , "detail" : name })
624- return changes
547+ _map_changes = core .diff_maps
548+ _set_changes = core .diff_sets
625549
626550
627551def diff (base : Mapping [str , Any ], current : Mapping [str , Any ]) -> List [Dict [str , str ]]:
628- common = set ( base . get ( "observed" , [])) & set ( current . get ( "observed" , []) )
552+ common = core . observed_categories ( base , current )
629553 changes : List [Dict [str , str ]] = []
630554
631555 # A baseline written before MEASUREMENT_SCOPE 2 holds skill digests over
632556 # SKILL.md alone, so comparing them against whole-directory digests would
633- # report every skill as changed. Drop skills from the comparison and say why.
634- base_scope = base .get ("scope" , 1 )
635- if base_scope != MEASUREMENT_SCOPE :
636- changes .append (
637- {
638- "change" : "changed" ,
639- "what" : "measurement scope" ,
640- "detail" : (
641- "widened from %s to %s; skill digests now cover the whole skill "
642- "directory. Re-approve once to compare on the new scope."
643- % (base_scope , MEASUREMENT_SCOPE )
644- ),
645- }
646- )
557+ # report every skill as changed. Drop skills and say why.
558+ scope = core .scope_change (
559+ base , MEASUREMENT_SCOPE ,
560+ affected = ["skills" ],
561+ reason = "skill digests now cover the whole skill directory." ,
562+ )
563+ if scope is not None :
564+ changes .append (scope )
647565 common .discard ("skills" )
648566
649567 if "instructions" in common :
@@ -834,34 +752,23 @@ def build_trace(current: Mapping[str, Any]) -> Dict[str, Any]:
834752
835753
836754def _atomic_write (path : Path , content : str ) -> None :
837- path .parent .mkdir (parents = True , exist_ok = True )
838- temporary : Optional [str ] = None
839- try :
840- with tempfile .NamedTemporaryFile (
841- mode = "w" ,
842- encoding = "utf-8" ,
843- dir = str (path .parent ),
844- prefix = ".%s." % path .name ,
845- delete = False ,
846- ) as handle :
847- handle .write (content )
848- handle .flush ()
849- os .fsync (handle .fileno ())
850- temporary = handle .name
851- try :
852- os .chmod (temporary , 0o600 )
853- except OSError :
854- pass
855- os .replace (temporary , path )
856- finally :
857- if temporary and os .path .exists (temporary ):
858- try :
859- os .unlink (temporary )
860- except OSError :
861- pass
755+ """Owner-only atomic write.
756+
757+ 0o600 rather than the core default, because _save also writes the Ed25519
758+ signing key. The core applies the mode before the replace, so the file is never
759+ briefly world-readable.
760+ """
761+ core .atomic_write (path , content , mode = 0o600 )
862762
863763
864764def _save (path : Path , value : Mapping [str , Any ]) -> None :
765+ """Serialise sorted with a trailing newline.
766+
767+ Kept local rather than using core.save_state: this engine's state files are
768+ sorted and newline-terminated, and switching the format would rewrite every
769+ existing file for no behavioural gain. The digest is computed over the mapping
770+ rather than the bytes, so the two formats are interchangeable in meaning.
771+ """
865772 _atomic_write (path , json .dumps (value , indent = 2 , sort_keys = True ) + "\n " )
866773
867774
@@ -953,7 +860,7 @@ def sign_all(
953860#: Shown wherever a category was not measured. An integrity report must not let
954861#: "we did not check" read like "we checked and there is nothing", because a
955862#: reader who cannot tell them apart will treat an absent measurement as a pass.
956- UNMEASURED = "not measured this run"
863+ UNMEASURED = core . UNMEASURED
957864
958865
959866def render_report (
0 commit comments