Skip to content
Merged
4 changes: 2 additions & 2 deletions .agents/skills/evidence-chain/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ description: Design evidence schemas, validate serialization, and plan hash chai

# Evidence Chain Skill

Draft and validate evidence event models, hash-chain sequences, and Merkle tree state logs.
Draft and validate evidence event models, hash-chain sequences, and state log chains.

## Use when
- Designing JSON schemas for evidence events and run records (e.g. `schemas/`).
- Refining event serialization modules and formatting sample evidence logs.
- Implementing or validating local hash-chain verification routines.
- Planning local Merkle tree state logs to link git commit hashes into the evidence chain.
- Planning local state log chains to link git commit hashes into the evidence chain.

## Do not use when
- Implementing live provider attestation or hosted cryptographic verification.
Expand Down
4 changes: 2 additions & 2 deletions .antigravity/plugins/comptext-local/skills/evidence-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ description: Design evidence schemas, validate serialization, and plan hash chai

# Evidence Chain Skill

Draft and validate evidence event models, hash-chain sequences, and Merkle tree state logs.
Draft and validate evidence event models, hash-chain sequences, and state log chains.

## Use when
- Designing JSON schemas for evidence events and run records (e.g. `schemas/`).
- Refining event serialization modules and formatting sample evidence logs.
- Implementing or validating local hash-chain verification routines.
- Planning local Merkle tree state logs to link git commit hashes into the evidence chain.
- Planning local state log chains to link git commit hashes into the evidence chain.

## Do not use when
- Implementing live provider attestation or hosted cryptographic verification.
Expand Down
2 changes: 1 addition & 1 deletion docs/ANTIGRAVITY_CLI_WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ comptext tui --dry-run
## Next Implementation Unit

The next planned unit of work is:
- **Offline Merkle Tree state log validation**: Building the local validation tools to link sequential git commit hashes directly into the evidence chain.
- **Offline state log chain validation**: Building the local validation tools to link sequential git commit hashes directly into the evidence chain.
4 changes: 2 additions & 2 deletions docs/LOCAL_V0_WORKSPACE_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ python -m pytest
# Run focused validation tests
python -m pytest tests/validation tests/evidence tests/cli

# Run Textual TUI smoke run
comptext tui --dry-run
# Run Textual TUI headless validation
python -m pytest tests/cli/test_tui.py

# Check git repository for formatting or whitespace issues
git diff --check
Expand Down
33 changes: 33 additions & 0 deletions examples/workspace/evidence-chain.sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[
{
"index": 0,
"type": "run.started",
"payload": {
"command": "comptext evidence verify --sample",
"mode": "sample"
},
"previous_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7"
},
{
"index": 1,
"type": "tool.completed",
"payload": {
"tool": "sample-check",
"status": "ok"
},
"previous_hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7",
"hash": "f2ff96cbd5d3deb8fcc1b480235ae43648cbab1055369934cfcd48d9454125c3"
},
{
"index": 2,
"type": "run.completed",
"payload": {
"status": "verified",
"network": "not_called",
"providers": "not_called"
},
"previous_hash": "f2ff96cbd5d3deb8fcc1b480235ae43648cbab1055369934cfcd48d9454125c3",
"hash": "4b686649a35a7b9d69828db72a679e868f2ddf2301052e80c10a1e58c8a65d82"
}
]
10 changes: 10 additions & 0 deletions examples/workspace/evidence-event.sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"index": 0,
"type": "run.started",
"payload": {
"command": "comptext evidence verify --sample",
"mode": "sample"
},
"previous_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7"
}
7 changes: 7 additions & 0 deletions examples/workspace/evidence-state-log-entry.sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"sequence": 0,
"evidence_event_hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7",
"git_commit_ref": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"previous_state_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"state_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
16 changes: 16 additions & 0 deletions examples/workspace/evidence-state-log.sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[
{
"sequence": 0,
"evidence_event_hash": "75e38380712627db05eb0d187f9e2a7ec809c7ab3ea4255bed0c9a96f55a5aa7",
"git_commit_ref": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"previous_state_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"state_hash": "33c34dd11e9d7ea0b6bbd2443f0a2c838ce0b05e6fa7feddc0c770d55349b53f"
},
{
"sequence": 1,
"evidence_event_hash": "f2ff96cbd5d3deb8fcc1b480235ae43648cbab1055369934cfcd48d9454125c3",
"git_commit_ref": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0a1",
"previous_state_hash": "33c34dd11e9d7ea0b6bbd2443f0a2c838ce0b05e6fa7feddc0c770d55349b53f",
"state_hash": "685473c529dc5910f21a05bcd0026fe70ca26410c634fb254ceceea55e94317e"
}
]
18 changes: 15 additions & 3 deletions modules/cli/cli_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Any

from modules.doctor.doctor import run_doctor
from modules.evidence.evidence import verify_sample_evidence
from modules.evidence.evidence import verify_file_evidence, verify_file_state_log, verify_sample_evidence
from modules.gateway.gateway import (
dry_run_gateway_response,
get_gateway_health,
Expand Down Expand Up @@ -53,7 +53,12 @@ def build_parser() -> argparse.ArgumentParser:
evidence = subparsers.add_parser("evidence")
evidence_subparsers = evidence.add_subparsers(dest="evidence_command", required=True)
evidence_verify = evidence_subparsers.add_parser("verify")
evidence_verify.add_argument("--sample", action="store_true", required=True)
group = evidence_verify.add_mutually_exclusive_group(required=True)
group.add_argument("--sample", action="store_true")
group.add_argument("--file", type=str)

evidence_verify_state = evidence_subparsers.add_parser("verify-state-log")
evidence_verify_state.add_argument("--file", type=str, required=True)

run_parser = subparsers.add_parser("run")
run_subparsers = run_parser.add_subparsers(dest="run_command", required=True)
Expand Down Expand Up @@ -140,7 +145,14 @@ def run(argv: list[str] | None = None, *, repo_root: Path | None = None) -> int:
_print_json(result)
return 0 if result.get("ok") is True else 1
if args.command == "evidence" and args.evidence_command == "verify":
result = verify_sample_evidence(sample=args.sample)
if args.sample:
result = verify_sample_evidence(sample=args.sample)
else:
result = verify_file_evidence(filepath=args.file)
_print_json(result)
return 0 if result.get("ok") is True else 1
if args.command == "evidence" and args.evidence_command == "verify-state-log":
result = verify_file_state_log(filepath=args.file)
_print_json(result)
return 0 if result.get("ok") is True else 1
if args.command == "run" and args.run_command == "sample":
Expand Down
5 changes: 4 additions & 1 deletion modules/cli/status_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ def build_status_screen(repo_root: Path) -> tuple[int, str]:
evidence_ok = False
try:
evidence_res = verify_sample_evidence(sample=True)
evidence_ok = bool(evidence_res.get("ok"))
state_log_path = repo_root / "examples/workspace/evidence-state-log.sample.json"
from modules.evidence.evidence import verify_file_state_log
state_log_res = verify_file_state_log(filepath=state_log_path)
evidence_ok = bool(evidence_res.get("ok")) and bool(state_log_res.get("ok"))
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The inline import of verify_file_state_log is unnecessary and can be moved to the top of the file alongside other imports from modules.evidence.evidence (such as verify_sample_evidence on line 9) to keep the imports clean and consistent with modules/cli/tui.py.

Suggested change
state_log_path = repo_root / "examples/workspace/evidence-state-log.sample.json"
from modules.evidence.evidence import verify_file_state_log
state_log_res = verify_file_state_log(filepath=state_log_path)
evidence_ok = bool(evidence_res.get("ok")) and bool(state_log_res.get("ok"))
state_log_path = repo_root / "examples/workspace/evidence-state-log.sample.json"
state_log_res = verify_file_state_log(filepath=state_log_path)
evidence_ok = bool(evidence_res.get("ok")) and bool(state_log_res.get("ok"))

except Exception:
pass

Expand Down
13 changes: 7 additions & 6 deletions modules/cli/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from modules.doctor.doctor import run_doctor
from modules.validation.workspace_validation import validate_workspace_schemas
from modules.runtime.sample_run import run_sample
from modules.evidence.evidence import verify_sample_evidence
from modules.evidence.evidence import verify_file_state_log, verify_sample_evidence


def build_tui_snapshot(repo_root: Path) -> dict[str, Any]:
Expand Down Expand Up @@ -48,7 +48,9 @@ def build_tui_snapshot(repo_root: Path) -> dict[str, Any]:
evidence_ok = False
try:
evidence_res = verify_sample_evidence(sample=True)
evidence_ok = bool(evidence_res.get("ok"))
state_log_path = repo_root / "examples/workspace/evidence-state-log.sample.json"
state_log_res = verify_file_state_log(filepath=state_log_path)
evidence_ok = bool(evidence_res.get("ok")) and bool(state_log_res.get("ok"))
except Exception:
pass

Expand All @@ -70,9 +72,8 @@ def build_tui_snapshot(repo_root: Path) -> dict[str, Any]:
workspace_validation = {
"ok": workspace_ok,
"results": [
{"example": "examples/workspace/workspace-snapshot.sample.json", "status": "valid" if workspace_ok else "invalid"},
{"example": "examples/workspace/workspace-delta.sample.json", "status": "valid" if workspace_ok else "invalid"},
{"example": "examples/workspace/reflection-gate.sample.json", "status": "valid" if workspace_ok else "invalid"}
{"example": r["example"].replace("\\", "/"), "status": r["status"]}
for r in workspace_results
]
}

Expand All @@ -87,7 +88,7 @@ def build_tui_snapshot(repo_root: Path) -> dict[str, Any]:
# 7. Evidence
evidence = {
"status": "pass" if evidence_ok else "fail",
"note": "Evidence chain: local dry-run summary available through existing CompText status/verify surfaces."
"note": "Evidence chain: local event chain and state log chain verified."
}

# 8. Providers
Expand Down
109 changes: 107 additions & 2 deletions modules/evidence/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,29 @@

import hashlib
import json
from pathlib import Path
from typing import Any

GENESIS_HASH = "0" * 64


def _canonical_json(value: Any) -> str:
def canonical_serialize(value: Any) -> str:
"""Return deterministic canonical JSON string with sorted keys and no optional whitespace."""
return json.dumps(value, separators=(",", ":"), sort_keys=True)


def hash_event_content(event_without_hash: dict[str, Any]) -> str:
"""Compute the SHA-256 hash of the canonical serialized event content without the hash field."""
content = {k: v for k, v in event_without_hash.items() if k != "hash"}
return hashlib.sha256(canonical_serialize(content).encode("utf-8")).hexdigest()


def _canonical_json(value: Any) -> str:
return canonical_serialize(value)


def _hash_event_content(event_without_hash: dict[str, Any]) -> str:
return hashlib.sha256(_canonical_json(event_without_hash).encode("utf-8")).hexdigest()
return hash_event_content(event_without_hash)


def _validate_payload(payload: Any) -> None:
Expand All @@ -24,6 +36,13 @@ def _validate_payload(payload: Any) -> None:
val = payload[ref_field]
if not isinstance(val, str):
raise ValueError(f"payload field {ref_field} must be a string ref, not {type(val).__name__}")
if "git_commit_ref" in payload:
val = payload["git_commit_ref"]
if not isinstance(val, str):
raise ValueError(f"payload field git_commit_ref must be a string ref, not {type(val).__name__}")
if len(val) != 40 or not all(c in "0123456789abcdefABCDEF" for c in val):
raise ValueError("payload field git_commit_ref must be a 40-character hex string")



def _make_event(*, index: int, event_type: str, payload: dict[str, Any], previous_hash: str) -> dict[str, Any]:
Expand Down Expand Up @@ -101,3 +120,89 @@ def verify_sample_evidence(*, sample: bool = True) -> dict[str, Any]:
"providers": "not_called",
**result,
}


def verify_file_evidence(*, filepath: str | Path) -> dict[str, Any]:
"""Load and verify a local JSON file containing an array of evidence events."""
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"Evidence file not found: {filepath}")

with open(path, "r", encoding="utf-8") as f:
events = json.load(f)

if not isinstance(events, list):
raise ValueError("Evidence file content must be a JSON array")

result = verify_evidence_chain(events)
return {
"command": f"comptext evidence verify --file {filepath}",
"mode": "file",
"network": "not_called",
"providers": "not_called",
**result,
}


def verify_state_log_chain(entries: list[dict[str, Any]]) -> dict[str, Any]:
"""Verify sequence, previous state hashes, git commit refs, and content hashes in a state log chain."""
previous_hash = GENESIS_HASH
Comment on lines +147 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The schema schemas/evidence-state-log.v0.schema.json defines a minItems constraint of 1 for the state log array. However, verify_state_log_chain currently accepts an empty list of entries and returns {"ok": True}. To ensure consistency between schema validation and cryptographic verification, we should explicitly reject empty state log chains.

def verify_state_log_chain(entries: list[dict[str, Any]]) -> dict[str, Any]:
    """Verify sequence, previous state hashes, git commit refs, and content hashes in a state log chain."""
    if not entries:
        return {"ok": False, "error": "state log chain must contain at least one entry"}
    previous_hash = GENESIS_HASH

for expected_sequence, entry in enumerate(entries):
if not isinstance(entry, dict):
return {"ok": False, "error": f"entry at sequence {expected_sequence} must be an object"}
if entry.get("sequence") != expected_sequence:
return {"ok": False, "error": f"entry sequence mismatch at {expected_sequence}"}
if entry.get("previous_state_hash") != previous_hash:
return {"ok": False, "error": f"previous state hash mismatch at sequence {expected_sequence}"}
expected_hash = entry.get("state_hash")
if not isinstance(expected_hash, str):
return {"ok": False, "error": f"entry state hash missing at sequence {expected_sequence}"}

# Validate git_commit_ref format
git_ref = entry.get("git_commit_ref")
if not isinstance(git_ref, str):
return {"ok": False, "error": f"git_commit_ref at sequence {expected_sequence} must be a string"}
if len(git_ref) != 40 or not all(c in "0123456789abcdefABCDEF" for c in git_ref):
return {"ok": False, "error": f"git_commit_ref at sequence {expected_sequence} must be a 40-character hex string"}

# Validate evidence_event_hash format
event_hash = entry.get("evidence_event_hash")
if not isinstance(event_hash, str):
return {"ok": False, "error": f"evidence_event_hash at sequence {expected_sequence} must be a string"}
if len(event_hash) != 64 or not all(c in "0123456789abcdefABCDEF" for c in event_hash):
return {"ok": False, "error": f"evidence_event_hash at sequence {expected_sequence} must be a 64-character hex string"}

# Compute hash of state log entry content (excluding state_hash itself)
entry_without_hash = {k: v for k, v in entry.items() if k != "state_hash"}
actual_hash = hashlib.sha256(canonical_serialize(entry_without_hash).encode("utf-8")).hexdigest()
if actual_hash != expected_hash:
return {"ok": False, "error": f"entry state hash mismatch at sequence {expected_sequence}"}
previous_hash = expected_hash

return {
"ok": True,
"entries": len(entries),
"root_hash": previous_hash,
}


def verify_file_state_log(*, filepath: str | Path) -> dict[str, Any]:
"""Load and verify a local JSON file containing an array of state log entries."""
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"State log file not found: {filepath}")

with open(path, "r", encoding="utf-8") as f:
entries = json.load(f)

if not isinstance(entries, list):
raise ValueError("State log file content must be a JSON array")

result = verify_state_log_chain(entries)
return {
"command": f"comptext evidence verify-state-log --file {filepath}",
"mode": "state-log",
"network": "not_called",
"providers": "not_called",
**result,
}
9 changes: 9 additions & 0 deletions modules/validation/schema_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ def validate_json_schema_instance(schema: dict[str, Any], instance: Any, locatio
if "enum" in schema and instance not in schema["enum"]:
raise ValueError(f"{location} must be one of {schema['enum']}")

if "pattern" in schema and isinstance(instance, str):
import re
if not re.match(schema["pattern"], instance):
raise ValueError(f"{location} does not match pattern {schema['pattern']}")
Comment on lines +46 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In JSON Schema, the pattern keyword is not implicitly anchored (it behaves as a partial match unless explicitly anchored with ^ and $). Using Python's re.match restricts the match to the beginning of the string, which can cause valid JSON instances to fail validation if the pattern is not anchored but matches later in the string. Using re.search is the correct way to implement JSON Schema pattern matching in Python.

Suggested change
if "pattern" in schema and isinstance(instance, str):
import re
if not re.match(schema["pattern"], instance):
raise ValueError(f"{location} does not match pattern {schema['pattern']}")
if "pattern" in schema and isinstance(instance, str):
import re
if not re.search(schema["pattern"], instance):
raise ValueError(f"{location} does not match pattern {schema['pattern']}")


if "minItems" in schema and isinstance(instance, list):
if len(instance) < schema["minItems"]:
raise ValueError(f"{location} must have at least {schema['minItems']} items")

if isinstance(instance, dict):
for key in schema.get("required", []):
if key not in instance:
Expand Down
12 changes: 12 additions & 0 deletions modules/validation/workspace_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
Path("schemas/reflection-gate.v0.schema.json"),
Path("examples/workspace/reflection-gate.sample.json"),
),
(
Path("schemas/evidence-event.v0.schema.json"),
Path("examples/workspace/evidence-event.sample.json"),
),
(
Path("schemas/evidence-state-log-entry.v0.schema.json"),
Path("examples/workspace/evidence-state-log-entry.sample.json"),
),
(
Path("schemas/evidence-state-log.v0.schema.json"),
Path("examples/workspace/evidence-state-log.sample.json"),
),
)


Expand Down
Loading
Loading