-
Notifications
You must be signed in to change notification settings - Fork 0
chore(tui): document non-interactive validation path #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
73dbb3f
e60cc62
ef7d79c
9d8773b
31212e6
754bb0b
699eb94
b0ec173
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | ||
| ] |
| 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" | ||
| } |
| 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" | ||
| } |
| 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" | ||
| } | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -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]: | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The schema 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, | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In JSON Schema, the
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
| 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: | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The inline import of
verify_file_state_logis unnecessary and can be moved to the top of the file alongside other imports frommodules.evidence.evidence(such asverify_sample_evidenceon line 9) to keep the imports clean and consistent withmodules/cli/tui.py.