Implement content hash system for integrity verification

- Add hash_utils.py module for SHA256 content hashing with normalized YAML
- Store config_hash in registry database on publish
- Include hash in download response for client verification
- Verify downloaded content matches registry hash on install
- Store registry_hash in local tool config for publish state tracking
- Show publish state indicators in Tools page UI:
  - Green checkmark: Published and up to date
  - Orange dot: Modified since last publish
  - No indicator: Local tool (never published)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-16 01:31:08 -04:00
co-authored by Claude Opus 4.5
parent 8e6b03cade
commit 78025aac8e
6 changed files with 240 additions and 10 deletions
+8 -2
View File
@@ -21,6 +21,7 @@ from argon2.exceptions import VerifyMismatchError
from .db import connect_db, init_db, query_all, query_one
from .rate_limit import RateLimiter
from .sync import process_webhook, get_categories_cache_path, get_repo_dir
from ..hash_utils import compute_yaml_hash
from .stats import (
refresh_tool_stats, get_tool_stats, refresh_publisher_stats,
get_publisher_stats, track_tool_usage, calculate_badges, BADGES,
@@ -996,6 +997,7 @@ def create_app() -> Flask:
"resolved_version": row["version"],
"config": row["config_yaml"],
"readme": row["readme"] or "",
"config_hash": row.get("config_hash") or "",
}
})
response.headers["Cache-Control"] = "max-age=3600, immutable"
@@ -1792,6 +1794,9 @@ def create_app() -> Flask:
tags_json = json.dumps(tags)
# Compute content hash for integrity verification
config_hash = compute_yaml_hash(config_text)
# Determine status based on scrutiny
if scrutiny_report and scrutiny_report.get("decision") == "approve":
scrutiny_status = "approved"
@@ -1816,8 +1821,8 @@ def create_app() -> Flask:
owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads,
scrutiny_status, scrutiny_report, source, source_url, source_json,
visibility, moderation_status, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
config_hash, visibility, moderation_status, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
@@ -1838,6 +1843,7 @@ def create_app() -> Flask:
source,
source_url,
source_json,
config_hash,
visibility,
moderation_status,
datetime.utcnow().isoformat(),
+3
View File
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS tools (
source TEXT,
source_url TEXT,
source_json TEXT,
config_hash TEXT,
visibility TEXT DEFAULT 'public',
moderation_status TEXT DEFAULT 'pending',
moderation_note TEXT,
@@ -448,6 +449,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
("source", "TEXT", "NULL"),
("source_url", "TEXT", "NULL"),
("source_json", "TEXT", "NULL"),
("config_hash", "TEXT", "NULL"),
("visibility", "TEXT", "'public'"),
("moderation_status", "TEXT", "'pending'"),
("moderation_note", "TEXT", "NULL"),
@@ -512,6 +514,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
try:
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_moderation ON tools(moderation_status, visibility)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_hash ON tools(config_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_role ON publishers(role)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_banned ON publishers(banned)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)")