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 <noreply@anthropic.com>
This commit is contained in:
parent
8e6b03cade
commit
78025aac8e
|
|
@ -1,19 +1,58 @@
|
||||||
"""Tools page - main view for managing tools."""
|
"""Tools page - main view for managing tools."""
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||||
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
||||||
QPushButton, QGroupBox, QMessageBox, QFrame
|
QPushButton, QGroupBox, QMessageBox, QFrame
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QFont, QColor, QBrush
|
||||||
|
|
||||||
from ...tool import (
|
from ...tool import (
|
||||||
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
||||||
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES
|
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES,
|
||||||
|
get_tools_dir
|
||||||
)
|
)
|
||||||
from ...config import load_config
|
from ...config import load_config
|
||||||
|
from ...hash_utils import compute_config_hash
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Get the publish state of a tool.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (state, registry_hash) where state is:
|
||||||
|
- "published" - has registry_hash and current hash matches
|
||||||
|
- "modified" - has registry_hash but current hash differs
|
||||||
|
- "local" - no registry_hash (never published/downloaded)
|
||||||
|
"""
|
||||||
|
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||||
|
if not config_path.exists():
|
||||||
|
return ("local", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = yaml.safe_load(config_path.read_text())
|
||||||
|
registry_hash = config.get("registry_hash")
|
||||||
|
|
||||||
|
if not registry_hash:
|
||||||
|
return ("local", None)
|
||||||
|
|
||||||
|
# Compute current hash (excluding hash fields)
|
||||||
|
current_hash = compute_config_hash(config)
|
||||||
|
|
||||||
|
if current_hash == registry_hash:
|
||||||
|
return ("published", registry_hash)
|
||||||
|
else:
|
||||||
|
return ("modified", registry_hash)
|
||||||
|
except Exception:
|
||||||
|
return ("local", None)
|
||||||
|
|
||||||
|
|
||||||
class ToolsPage(QWidget):
|
class ToolsPage(QWidget):
|
||||||
|
|
@ -169,10 +208,34 @@ class ToolsPage(QWidget):
|
||||||
|
|
||||||
# Tools in category
|
# Tools in category
|
||||||
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
|
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
|
||||||
tool_item = QTreeWidgetItem([name])
|
# Get publish state
|
||||||
|
state, registry_hash = get_tool_publish_state(name)
|
||||||
|
|
||||||
|
# Show state indicator in display name
|
||||||
|
if state == "published":
|
||||||
|
display_name = f"{name} ✓"
|
||||||
|
tooltip = "Published to registry - up to date"
|
||||||
|
color = QColor(56, 161, 105) # Green
|
||||||
|
elif state == "modified":
|
||||||
|
display_name = f"{name} ●"
|
||||||
|
tooltip = "Published to registry - local modifications"
|
||||||
|
color = QColor(221, 107, 32) # Orange
|
||||||
|
else:
|
||||||
|
display_name = name
|
||||||
|
tooltip = "Local tool - not published"
|
||||||
|
color = None
|
||||||
|
|
||||||
|
tool_item = QTreeWidgetItem([display_name])
|
||||||
tool_item.setData(0, Qt.UserRole, name)
|
tool_item.setData(0, Qt.UserRole, name)
|
||||||
|
|
||||||
|
if color:
|
||||||
|
tool_item.setForeground(0, QBrush(color))
|
||||||
|
|
||||||
|
# Build tooltip
|
||||||
if tool.source and tool.source.type == "imported":
|
if tool.source and tool.source.type == "imported":
|
||||||
tool_item.setToolTip(0, f"Imported from {tool.source.url or 'registry'}")
|
tooltip = f"Imported from {tool.source.url or 'registry'}"
|
||||||
|
tool_item.setToolTip(0, tooltip)
|
||||||
|
|
||||||
cat_item.addChild(tool_item)
|
cat_item.addChild(tool_item)
|
||||||
|
|
||||||
self.tool_tree.addTopLevelItem(cat_item)
|
self.tool_tree.addTopLevelItem(cat_item)
|
||||||
|
|
@ -224,6 +287,21 @@ class ToolsPage(QWidget):
|
||||||
if tool.description:
|
if tool.description:
|
||||||
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
|
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
|
||||||
|
|
||||||
|
# Publish state
|
||||||
|
state, registry_hash = get_tool_publish_state(tool.name)
|
||||||
|
if state == "published":
|
||||||
|
lines.append(
|
||||||
|
"<p style='background: #c6f6d5; color: #276749; padding: 6px 10px; "
|
||||||
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
||||||
|
"✓ Published to registry - up to date</p>"
|
||||||
|
)
|
||||||
|
elif state == "modified":
|
||||||
|
lines.append(
|
||||||
|
"<p style='background: #feebc8; color: #c05621; padding: 6px 10px; "
|
||||||
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
||||||
|
"● Modified since last publish - republish to update registry</p>"
|
||||||
|
)
|
||||||
|
|
||||||
# Source info
|
# Source info
|
||||||
if tool.source:
|
if tool.source:
|
||||||
source_type = tool.source.type
|
source_type = tool.source.type
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""Content hash utilities for tool integrity verification.
|
||||||
|
|
||||||
|
This module provides consistent SHA256 hashing for tool content,
|
||||||
|
used for:
|
||||||
|
- Publish state tracking (detect local modifications)
|
||||||
|
- Download integrity verification
|
||||||
|
- Registry content verification
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def compute_config_hash(config: Dict[str, Any], exclude_fields: Optional[list] = None) -> str:
|
||||||
|
"""Compute SHA256 hash of tool configuration.
|
||||||
|
|
||||||
|
The hash is computed from a normalized YAML representation to ensure
|
||||||
|
consistent hashing regardless of field order or formatting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Tool configuration dictionary
|
||||||
|
exclude_fields: Fields to exclude from hashing (e.g., 'published_hash', 'registry_hash')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash string in format "sha256:<64-char-hex>"
|
||||||
|
"""
|
||||||
|
if exclude_fields is None:
|
||||||
|
exclude_fields = ['published_hash', 'registry_hash']
|
||||||
|
|
||||||
|
# Create a copy without excluded fields
|
||||||
|
config_copy = {k: v for k, v in config.items() if k not in exclude_fields}
|
||||||
|
|
||||||
|
# Normalize to YAML with sorted keys for consistent ordering
|
||||||
|
normalized = yaml.dump(config_copy, sort_keys=True, default_flow_style=False)
|
||||||
|
|
||||||
|
# Compute SHA256
|
||||||
|
hash_bytes = hashlib.sha256(normalized.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
return f"sha256:{hash_bytes}"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_yaml_hash(yaml_content: str, exclude_fields: Optional[list] = None) -> str:
|
||||||
|
"""Compute SHA256 hash of YAML content string.
|
||||||
|
|
||||||
|
Parses the YAML, normalizes it, and computes hash.
|
||||||
|
Useful for hashing raw config.yaml content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
yaml_content: Raw YAML string
|
||||||
|
exclude_fields: Fields to exclude from hashing
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash string in format "sha256:<64-char-hex>"
|
||||||
|
"""
|
||||||
|
config = yaml.safe_load(yaml_content)
|
||||||
|
if config is None:
|
||||||
|
config = {}
|
||||||
|
return compute_config_hash(config, exclude_fields)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_file_hash(file_path: str) -> str:
|
||||||
|
"""Compute SHA256 hash of a file's contents.
|
||||||
|
|
||||||
|
Used for hashing pattern files (e.g., Fabric's system.md).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash string in format "sha256:<64-char-hex>"
|
||||||
|
"""
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
hash_bytes = hashlib.sha256(f.read()).hexdigest()
|
||||||
|
return f"sha256:{hash_bytes}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_hash(content: str, expected_hash: str) -> bool:
|
||||||
|
"""Verify content matches expected hash.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content to verify (YAML string)
|
||||||
|
expected_hash: Expected hash in format "sha256:<hex>"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if hash matches, False otherwise
|
||||||
|
"""
|
||||||
|
if not expected_hash or not expected_hash.startswith("sha256:"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
computed = compute_yaml_hash(content)
|
||||||
|
return computed == expected_hash
|
||||||
|
|
||||||
|
|
||||||
|
def extract_hash_value(hash_string: str) -> Optional[str]:
|
||||||
|
"""Extract the hex value from a hash string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hash_string: Hash in format "sha256:<hex>"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The hex value without prefix, or None if invalid
|
||||||
|
"""
|
||||||
|
if not hash_string or not hash_string.startswith("sha256:"):
|
||||||
|
return None
|
||||||
|
return hash_string[7:] # Remove "sha256:" prefix
|
||||||
|
|
||||||
|
|
||||||
|
def short_hash(hash_string: str, length: int = 8) -> str:
|
||||||
|
"""Get a shortened version of a hash for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hash_string: Full hash string
|
||||||
|
length: Number of hex chars to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shortened hash (e.g., "sha256:abc123...")
|
||||||
|
"""
|
||||||
|
hex_value = extract_hash_value(hash_string)
|
||||||
|
if hex_value:
|
||||||
|
return f"sha256:{hex_value[:length]}..."
|
||||||
|
return hash_string[:length + 10] + "..."
|
||||||
|
|
@ -21,6 +21,7 @@ from argon2.exceptions import VerifyMismatchError
|
||||||
from .db import connect_db, init_db, query_all, query_one
|
from .db import connect_db, init_db, query_all, query_one
|
||||||
from .rate_limit import RateLimiter
|
from .rate_limit import RateLimiter
|
||||||
from .sync import process_webhook, get_categories_cache_path, get_repo_dir
|
from .sync import process_webhook, get_categories_cache_path, get_repo_dir
|
||||||
|
from ..hash_utils import compute_yaml_hash
|
||||||
from .stats import (
|
from .stats import (
|
||||||
refresh_tool_stats, get_tool_stats, refresh_publisher_stats,
|
refresh_tool_stats, get_tool_stats, refresh_publisher_stats,
|
||||||
get_publisher_stats, track_tool_usage, calculate_badges, BADGES,
|
get_publisher_stats, track_tool_usage, calculate_badges, BADGES,
|
||||||
|
|
@ -996,6 +997,7 @@ def create_app() -> Flask:
|
||||||
"resolved_version": row["version"],
|
"resolved_version": row["version"],
|
||||||
"config": row["config_yaml"],
|
"config": row["config_yaml"],
|
||||||
"readme": row["readme"] or "",
|
"readme": row["readme"] or "",
|
||||||
|
"config_hash": row.get("config_hash") or "",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
response.headers["Cache-Control"] = "max-age=3600, immutable"
|
response.headers["Cache-Control"] = "max-age=3600, immutable"
|
||||||
|
|
@ -1792,6 +1794,9 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
tags_json = json.dumps(tags)
|
tags_json = json.dumps(tags)
|
||||||
|
|
||||||
|
# Compute content hash for integrity verification
|
||||||
|
config_hash = compute_yaml_hash(config_text)
|
||||||
|
|
||||||
# Determine status based on scrutiny
|
# Determine status based on scrutiny
|
||||||
if scrutiny_report and scrutiny_report.get("decision") == "approve":
|
if scrutiny_report and scrutiny_report.get("decision") == "approve":
|
||||||
scrutiny_status = "approved"
|
scrutiny_status = "approved"
|
||||||
|
|
@ -1816,8 +1821,8 @@ def create_app() -> Flask:
|
||||||
owner, name, version, description, category, tags, config_yaml, readme,
|
owner, name, version, description, category, tags, config_yaml, readme,
|
||||||
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
||||||
scrutiny_status, scrutiny_report, source, source_url, source_json,
|
scrutiny_status, scrutiny_report, source, source_url, source_json,
|
||||||
visibility, moderation_status, published_at
|
config_hash, visibility, moderation_status, published_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
[
|
[
|
||||||
owner,
|
owner,
|
||||||
|
|
@ -1838,6 +1843,7 @@ def create_app() -> Flask:
|
||||||
source,
|
source,
|
||||||
source_url,
|
source_url,
|
||||||
source_json,
|
source_json,
|
||||||
|
config_hash,
|
||||||
visibility,
|
visibility,
|
||||||
moderation_status,
|
moderation_status,
|
||||||
datetime.utcnow().isoformat(),
|
datetime.utcnow().isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS tools (
|
||||||
source TEXT,
|
source TEXT,
|
||||||
source_url TEXT,
|
source_url TEXT,
|
||||||
source_json TEXT,
|
source_json TEXT,
|
||||||
|
config_hash TEXT,
|
||||||
visibility TEXT DEFAULT 'public',
|
visibility TEXT DEFAULT 'public',
|
||||||
moderation_status TEXT DEFAULT 'pending',
|
moderation_status TEXT DEFAULT 'pending',
|
||||||
moderation_note TEXT,
|
moderation_note TEXT,
|
||||||
|
|
@ -448,6 +449,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||||
("source", "TEXT", "NULL"),
|
("source", "TEXT", "NULL"),
|
||||||
("source_url", "TEXT", "NULL"),
|
("source_url", "TEXT", "NULL"),
|
||||||
("source_json", "TEXT", "NULL"),
|
("source_json", "TEXT", "NULL"),
|
||||||
|
("config_hash", "TEXT", "NULL"),
|
||||||
("visibility", "TEXT", "'public'"),
|
("visibility", "TEXT", "'public'"),
|
||||||
("moderation_status", "TEXT", "'pending'"),
|
("moderation_status", "TEXT", "'pending'"),
|
||||||
("moderation_note", "TEXT", "NULL"),
|
("moderation_note", "TEXT", "NULL"),
|
||||||
|
|
@ -512,6 +514,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||||
try:
|
try:
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner)")
|
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_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_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_publishers_banned ON publishers(banned)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)")
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ class DownloadResult:
|
||||||
resolved_version: str
|
resolved_version: str
|
||||||
config_yaml: str
|
config_yaml: str
|
||||||
readme: str = ""
|
readme: str = ""
|
||||||
|
config_hash: str = "" # Registry hash for integrity verification
|
||||||
|
|
||||||
|
|
||||||
class RegistryClient:
|
class RegistryClient:
|
||||||
|
|
@ -544,7 +545,8 @@ class RegistryClient:
|
||||||
name=data.get("name", name),
|
name=data.get("name", name),
|
||||||
resolved_version=data.get("resolved_version", ""),
|
resolved_version=data.get("resolved_version", ""),
|
||||||
config_yaml=data.get("config", ""),
|
config_yaml=data.get("config", ""),
|
||||||
readme=data.get("readme", "")
|
readme=data.get("readme", ""),
|
||||||
|
config_hash=data.get("config_hash", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_categories(self) -> List[Dict[str, Any]]:
|
def get_categories(self) -> List[Dict[str, Any]]:
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,8 @@ class ToolResolver:
|
||||||
name=result.name,
|
name=result.name,
|
||||||
version=result.resolved_version,
|
version=result.resolved_version,
|
||||||
config_yaml=result.config_yaml,
|
config_yaml=result.config_yaml,
|
||||||
readme=result.readme
|
readme=result.readme,
|
||||||
|
config_hash=result.config_hash
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
|
|
@ -379,13 +380,30 @@ class ToolResolver:
|
||||||
name: str,
|
name: str,
|
||||||
version: str,
|
version: str,
|
||||||
config_yaml: str,
|
config_yaml: str,
|
||||||
readme: str = ""
|
readme: str = "",
|
||||||
|
config_hash: str = ""
|
||||||
) -> ResolvedTool:
|
) -> ResolvedTool:
|
||||||
"""Install a tool fetched from registry to global directory."""
|
"""Install a tool fetched from registry to global directory."""
|
||||||
|
# Verify hash if provided
|
||||||
|
if config_hash:
|
||||||
|
from .hash_utils import compute_yaml_hash
|
||||||
|
computed_hash = compute_yaml_hash(config_yaml)
|
||||||
|
if computed_hash != config_hash:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Hash mismatch for {owner}/{name}: expected {config_hash[:20]}..., "
|
||||||
|
f"got {computed_hash[:20]}... - content may have been tampered with"
|
||||||
|
)
|
||||||
|
|
||||||
# Create directory structure
|
# Create directory structure
|
||||||
tool_dir = TOOLS_DIR / owner / name
|
tool_dir = TOOLS_DIR / owner / name
|
||||||
tool_dir.mkdir(parents=True, exist_ok=True)
|
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Add registry_hash to config so we can track publish state
|
||||||
|
if config_hash:
|
||||||
|
parsed_config = yaml.safe_load(config_yaml)
|
||||||
|
parsed_config["registry_hash"] = config_hash
|
||||||
|
config_yaml = yaml.dump(parsed_config, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
# Write config
|
# Write config
|
||||||
config_path = tool_dir / "config.yaml"
|
config_path = tool_dir / "config.yaml"
|
||||||
config_path.write_text(config_yaml)
|
config_path.write_text(config_yaml)
|
||||||
|
|
@ -593,7 +611,8 @@ def install_from_registry(spec: str, version: Optional[str] = None) -> ResolvedT
|
||||||
name=result.name,
|
name=result.name,
|
||||||
version=result.resolved_version,
|
version=result.resolved_version,
|
||||||
config_yaml=result.config_yaml,
|
config_yaml=result.config_yaml,
|
||||||
readme=result.readme
|
readme=result.readme,
|
||||||
|
config_hash=result.config_hash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue