1- """Client-attested build provenance capture (AGX1-418).
2-
3- The single producer of source identity for agent builds: git coordinates plus a
4- deterministic content hash of the build context. Every build path (CLI, sgpctl,
5- CI) imports this so capture logic and the ``working_tree_hash`` definition live
6- in exactly one place. Capture is best-effort — a missing/odd git state degrades
7- to nulls and never raises into a build.
8- """
1+ """Capture client-attested source identity without failing agent builds."""
92
103from __future__ import annotations
114
2821
2922@dataclass (frozen = True )
3023class BuildProvenance :
31- """Source identity for one build; every field degrades to ``None``.
32-
33- ``working_tree_hash`` is the deterministic content hash of the build context
34- and is always computed — it identifies the exact bytes that shipped,
35- independent of git state. ``commit`` (+ ``ref`` / ``repo``) anchor those bytes
36- to source, and ``dirty`` records whether the work tree had uncommitted changes
37- at build time (``None`` outside a git work tree).
38- """
24+ """Source identity for one build; unavailable fields degrade to ``None``."""
3925
4026 repo : Optional [str ] = None
4127 commit : Optional [str ] = None
@@ -60,13 +46,7 @@ def source_fields(self) -> dict[str, object]:
6046 return {key : value for key , value in fields .items () if value is not None }
6147
6248 def build_info (self ) -> dict [str , object ]:
63- """The ``build-info.json`` payload (runtime ``registration_metadata``).
64-
65- Overlapping keys match the server's ``DeploymentHistory`` type
66- (``commit_hash`` / ``branch_name`` / ``author_*`` / ``build_timestamp``),
67- which is populated from ``registration_metadata``; the rest are the
68- provenance-specific coordinates.
69- """
49+ """Return provenance using the runtime registration metadata field names."""
7050 info = {
7151 "repo" : self .repo ,
7252 "commit_hash" : self .commit ,
@@ -99,12 +79,7 @@ def _git(repo_root: Path, *args: str) -> Optional[str]:
9979
10080
10181def normalize_remote (url : Optional [str ]) -> Optional [str ]:
102- """Canonicalize a git remote to ``host/path`` — credentials and scheme stripped.
103-
104- ``git@github.com:org/repo.git`` and ``https://x:tok@github.com/org/repo.git``
105- both normalize to ``github.com/org/repo``. Host is lowercased; path casing is
106- preserved (repo paths can be case-significant).
107- """
82+ """Strip credentials and scheme from a remote, returning ``host/path``."""
10883 if not url :
10984 return None
11085 candidate = url .strip ()
@@ -133,25 +108,15 @@ def _sha256_file(path: Path) -> str:
133108
134109
135110def iter_context_files (root : Path ) -> list [Path ]:
136- """Files (and symlinks) under ``root``, sorted by POSIX relpath.
137-
138- The canonical, order-stable enumeration shared by the content hash and the
139- archive packer so the two can never drift on which files they cover.
140- """
111+ """Return files and symlinks under ``root``, sorted by POSIX relative path."""
141112 return sorted (
142113 (path for path in root .rglob ("*" ) if path .is_symlink () or path .is_file ()),
143114 key = lambda path : path .relative_to (root ).as_posix (),
144115 )
145116
146117
147118def working_tree_hash (root : Path ) -> str :
148- """Deterministic content hash of the build context at ``root``.
149-
150- sha256 over the sorted ``(relpath, normalized mode, content digest)`` of every
151- file — the build *inputs*, not the tarball (tar/gzip framing is
152- non-deterministic and would defeat dedupe). Mode is normalized to the
153- executable bit; symlinks hash their target string, not the resolved content.
154- """
119+ """Hash sorted build inputs, normalized modes, and symlink target strings."""
155120 lines : list [str ] = []
156121 for path in iter_context_files (root ):
157122 relpath = path .relative_to (root ).as_posix ()
@@ -167,11 +132,7 @@ def working_tree_hash(root: Path) -> str:
167132
168133
169134def _safe_working_tree_hash (root : Path ) -> Optional [str ]:
170- """``working_tree_hash`` that degrades to None — capture must never fail a build.
171-
172- A permission error or filesystem race during the walk/stat/read would otherwise
173- raise out of capture and abort the build before the archive is even created.
174- """
135+ """Compute the context hash without allowing capture to fail a build."""
175136 try :
176137 return working_tree_hash (root )
177138 except Exception :
@@ -182,15 +143,7 @@ def _safe_working_tree_hash(root: Path) -> Optional[str]:
182143def capture_build_provenance (
183144 repo_path : Path , context_root : Path , content_root : Optional [Path ] = None
184145) -> BuildProvenance :
185- """Capture source identity for a build of ``context_root``.
186-
187- ``repo_path`` is where git is interrogated and ``subpath`` is ``context_root``
188- relative to the repo root (which agent, in a monorepo). ``content_root`` is
189- the directory hashed — the *staged*, post-``.dockerignore`` tree that actually
190- ships; it defaults to ``context_root`` when there is no separate staging dir.
191- ``working_tree_hash`` is always computed; git coordinates anchor it to source
192- when available.
193- """
146+ """Capture git coordinates and the staged build-context hash."""
194147 timestamp = datetime .now (timezone .utc ).isoformat ()
195148 hash_root = content_root if content_root is not None else context_root
196149 tree_hash = _safe_working_tree_hash (hash_root )
0 commit comments