Skip to content

Commit 12422a0

Browse files
Merge remote-tracking branch 'origin/main' into feat/copilot-integration
2 parents b4ae748 + 903a0de commit 12422a0

6 files changed

Lines changed: 120 additions & 184 deletions

File tree

  • claude-code/engine/_vendor/agentrust_capture_core
  • packages/agentrust-capture-core/src/agentrust_capture_core
  • plugins/agentrust-codex/engine
  • scheduled-agents/engine

‎claude-code/engine/_vendor/agentrust_capture_core/state.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@ class StatePaths:
2828
latest: Path
2929

3030

31-
def atomic_write(path: Path, content: str) -> None:
31+
def atomic_write(path: Path, content: str, *, mode: int | None = None) -> None:
3232
"""Write via a temporary file and replace, so a crash cannot truncate state.
3333
3434
A half-written baseline is worse than a missing one: the engine would treat it
3535
as corrupt on every future session, and a user who sees a broken check often
3636
enough stops reading it.
37+
38+
``mode`` is applied to the temporary file before the replace, so the file is
39+
never briefly readable at wider permissions than intended. Callers that write
40+
a private key pass ``0o600``. Best-effort, since not every filesystem carries
41+
POSIX permissions.
3742
"""
3843
path.parent.mkdir(parents=True, exist_ok=True)
3944
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
@@ -43,6 +48,11 @@ def atomic_write(path: Path, content: str) -> None:
4348
fh.write(content)
4449
fh.flush()
4550
os.fsync(fh.fileno())
51+
if mode is not None:
52+
try:
53+
os.chmod(tmp, mode)
54+
except OSError:
55+
pass
4656
os.replace(tmp, path)
4757
except BaseException:
4858
tmp.unlink(missing_ok=True)

‎packages/agentrust-capture-core/src/agentrust_capture_core/state.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@ class StatePaths:
2828
latest: Path
2929

3030

31-
def atomic_write(path: Path, content: str) -> None:
31+
def atomic_write(path: Path, content: str, *, mode: int | None = None) -> None:
3232
"""Write via a temporary file and replace, so a crash cannot truncate state.
3333
3434
A half-written baseline is worse than a missing one: the engine would treat it
3535
as corrupt on every future session, and a user who sees a broken check often
3636
enough stops reading it.
37+
38+
``mode`` is applied to the temporary file before the replace, so the file is
39+
never briefly readable at wider permissions than intended. Callers that write
40+
a private key pass ``0o600``. Best-effort, since not every filesystem carries
41+
POSIX permissions.
3742
"""
3843
path.parent.mkdir(parents=True, exist_ok=True)
3944
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
@@ -43,6 +48,11 @@ def atomic_write(path: Path, content: str) -> None:
4348
fh.write(content)
4449
fh.flush()
4550
os.fsync(fh.fileno())
51+
if mode is not None:
52+
try:
53+
os.chmod(tmp, mode)
54+
except OSError:
55+
pass
4656
os.replace(tmp, path)
4757
except BaseException:
4858
tmp.unlink(missing_ok=True)

‎plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/state.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@ class StatePaths:
2828
latest: Path
2929

3030

31-
def atomic_write(path: Path, content: str) -> None:
31+
def atomic_write(path: Path, content: str, *, mode: int | None = None) -> None:
3232
"""Write via a temporary file and replace, so a crash cannot truncate state.
3333
3434
A half-written baseline is worse than a missing one: the engine would treat it
3535
as corrupt on every future session, and a user who sees a broken check often
3636
enough stops reading it.
37+
38+
``mode`` is applied to the temporary file before the replace, so the file is
39+
never briefly readable at wider permissions than intended. Callers that write
40+
a private key pass ``0o600``. Best-effort, since not every filesystem carries
41+
POSIX permissions.
3742
"""
3843
path.parent.mkdir(parents=True, exist_ok=True)
3944
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
@@ -43,6 +48,11 @@ def atomic_write(path: Path, content: str) -> None:
4348
fh.write(content)
4449
fh.flush()
4550
os.fsync(fh.fileno())
51+
if mode is not None:
52+
try:
53+
os.chmod(tmp, mode)
54+
except OSError:
55+
pass
4656
os.replace(tmp, path)
4757
except BaseException:
4858
tmp.unlink(missing_ok=True)

‎plugins/agentrust-codex/engine/capture.py‎

Lines changed: 54 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,21 @@
1717
import re
1818
import socket
1919
import sys
20-
import tempfile
2120
import time
22-
import uuid
2321
from datetime import datetime, timedelta, timezone
2422
from pathlib import Path
2523
from 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

2836
VERSION = "0.1.0"
2937

@@ -60,20 +68,21 @@
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

7678
def _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-
9994
def _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

276227
def _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

627551
def 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

836754
def _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

864764
def _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

959866
def render_report(

‎scheduled-agents/engine/_vendor/agentrust_capture_core/state.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@ class StatePaths:
2828
latest: Path
2929

3030

31-
def atomic_write(path: Path, content: str) -> None:
31+
def atomic_write(path: Path, content: str, *, mode: int | None = None) -> None:
3232
"""Write via a temporary file and replace, so a crash cannot truncate state.
3333
3434
A half-written baseline is worse than a missing one: the engine would treat it
3535
as corrupt on every future session, and a user who sees a broken check often
3636
enough stops reading it.
37+
38+
``mode`` is applied to the temporary file before the replace, so the file is
39+
never briefly readable at wider permissions than intended. Callers that write
40+
a private key pass ``0o600``. Best-effort, since not every filesystem carries
41+
POSIX permissions.
3742
"""
3843
path.parent.mkdir(parents=True, exist_ok=True)
3944
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
@@ -43,6 +48,11 @@ def atomic_write(path: Path, content: str) -> None:
4348
fh.write(content)
4449
fh.flush()
4550
os.fsync(fh.fileno())
51+
if mode is not None:
52+
try:
53+
os.chmod(tmp, mode)
54+
except OSError:
55+
pass
4656
os.replace(tmp, path)
4757
except BaseException:
4858
tmp.unlink(missing_ok=True)

0 commit comments

Comments
 (0)