Harden M8 preflight foundation

This commit is contained in:
rob
2026-07-20 12:08:38 -03:00
parent 188e167c1c
commit ba29e4dc4e
14 changed files with 530 additions and 53 deletions
+9 -1
View File
@@ -93,6 +93,10 @@ def main():
# 'inspect' command
p_inspect = subparsers.add_parser("inspect", help="Run preflight analysis on a tool")
p_inspect.add_argument("name", help="Tool name")
p_inspect.add_argument(
"--registry", action="store_true",
help="Include similar tools from the configured registry",
)
p_inspect.set_defaults(func=cmd_inspect)
# 'check' command
@@ -530,7 +534,11 @@ def cmd_inspect(args):
print(f"Error: Tool '{args.name}' not found.", file=sys.stderr)
return 1
report = analyze_tool(tool)
registry_client = None
if getattr(args, "registry", False):
from ..registry_client import get_client
registry_client = get_client()
report = analyze_tool(tool, registry_client=registry_client)
if report.errors:
print(f"Errors ({len(report.errors)}):")
+78 -28
View File
@@ -351,6 +351,48 @@ def _cmd_registry_update(args):
return 0
def _print_preflight_sections(report: dict, prefix: str = "") -> None:
"""Render the common portions of a structured preflight report."""
labels = (
("errors", "ERROR"),
("warnings", "WARN"),
("suggestions", "HINT"),
)
for key, marker in labels:
values = report.get(key) or []
if values:
print(f"{prefix}{key.title()} ({len(values)}):")
for value in values:
print(f" {marker}: {value}")
def _print_registry_suggestions(suggestions: dict) -> None:
"""Render registry-specific category, similarity, and scrutiny evidence."""
category = suggestions.get("category") or {}
if category.get("suggested"):
print(
f"Suggested category: {category['suggested']} "
f"({category.get('confidence', 0):.0%} confidence)"
)
similar = suggestions.get("similar_tools") or []
if similar:
print(f"Similar registry tools ({len(similar)}):")
for item in similar:
print(
f" - {item.get('name', 'unknown')} "
f"({item.get('similarity', 0):.0%} similar)"
)
scrutiny = suggestions.get("scrutiny") or {}
findings = scrutiny.get("findings") or []
if findings:
print(f"Scrutiny findings ({len(findings)}):")
for finding in findings:
marker = str(finding.get("result", "info")).upper()
print(f" {marker}: {finding.get('message', '')}")
def _cmd_registry_publish(args):
"""Publish a tool to the registry."""
from ..registry_client import RegistryError, get_client
@@ -404,6 +446,9 @@ def _cmd_registry_publish(args):
# Validate
try:
data = yaml.safe_load(config_yaml)
if not isinstance(data, dict):
print("Error: config.yaml must contain a YAML mapping", file=sys.stderr)
return 1
name = data.get("name", "")
version = data.get("version", "")
if not name or not version:
@@ -424,36 +469,41 @@ def _cmd_registry_publish(args):
print(f" README: {len(readme)} bytes")
print()
from ..preflight import analyze_tool
from ..tool import Tool
try:
local_report = analyze_tool(Tool.from_dict(data))
except (KeyError, TypeError, ValueError) as exc:
print(f"Local preflight error: {exc}", file=sys.stderr)
return 1
_print_preflight_sections(local_report.to_dict(), prefix="Local ")
if not local_report.ok:
return 1
# Attempt registry preflight if token is configured
config = load_config()
if config.registry.token:
try:
client = get_client()
result = client.publish_tool(
config_yaml, readme=readme, defaults=defaults,
dry_run=True,
)
except Exception as e:
print(f"Registry preflight error: {e}", file=sys.stderr)
return 1
errors = result.get("errors") or []
warnings = result.get("warnings") or []
suggestions = result.get("suggestions") or []
if errors:
print(f"Preflight errors ({len(errors)}):")
for err in errors:
print(f" ERROR: {err}")
if warnings:
print(f"Preflight warnings ({len(warnings)}):")
for warn in warnings:
print(f" WARN: {warn}")
if suggestions:
print(f"Suggestions ({len(suggestions)}):")
for sug in suggestions:
print(f" HINT: {sug}")
if not errors and not warnings and not suggestions:
print("Registry preflight passed.")
return 0 if not errors else 1
if not config.registry.token:
print("No registry token configured — local preflight only.")
return 0
try:
client = get_client()
result = client.publish_tool(
config_yaml, readme=readme, defaults=defaults,
dry_run=True,
)
except RegistryError as exc:
print(f"Registry preflight failed: {exc}", file=sys.stderr)
return 1
remote_report = result.get("preflight") or {}
_print_preflight_sections(remote_report, prefix="Registry ")
_print_registry_suggestions(result.get("suggestions") or {})
if not remote_report.get("errors"):
print("Registry preflight passed.")
return 0
return 1
# Check for token
config = load_config()
+26 -3
View File
@@ -19,6 +19,12 @@ from ...tool import (
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
def _switch_to_existing_tool(main_window, name: str) -> None:
"""Remove the creation page before opening an existing tool editor."""
main_window.close_tool_builder()
main_window.open_tool_builder(name)
class ToolBuilderPage(QWidget):
"""Tool builder/editor page."""
@@ -1093,7 +1099,7 @@ class ToolBuilderPage(QWidget):
clicked = msg.clickedButton()
if clicked is btn_open:
self.main_window.open_tool_builder(name)
_switch_to_existing_tool(self.main_window, name)
return
elif clicked is btn_copy:
suffix = 2
@@ -1122,9 +1128,23 @@ class ToolBuilderPage(QWidget):
steps=self._tool.steps if self._tool else [],
output=output,
dependencies=self._tool.dependencies if self._tool else [],
system_dependencies=self._tool.system_dependencies if self._tool else []
system_dependencies=self._tool.system_dependencies if self._tool else [],
visibility=self._tool.visibility if self._tool else "public",
input_schema=self._tool.input_schema if self._tool else None,
output_schema=self._tool.output_schema if self._tool else None,
)
from ...preflight import analyze_tool
preflight = analyze_tool(tool)
if preflight.errors:
QMessageBox.warning(
self,
"Preflight Failed",
"\n".join(preflight.errors),
)
return
advisory_count = len(preflight.warnings) + len(preflight.suggestions)
# Preserve source if editing
if self._tool and self._tool.source:
tool.source = self._tool.source
@@ -1159,7 +1179,10 @@ class ToolBuilderPage(QWidget):
if settings_path.exists():
settings_path.unlink()
self.main_window.show_status(f"Saved tool '{name}'")
status = f"Saved tool '{name}'"
if advisory_count:
status += f" with {advisory_count} preflight suggestion(s)"
self.main_window.show_status(status)
self.main_window.close_tool_builder()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save tool:\n{e}")
+27 -15
View File
@@ -4,11 +4,10 @@ Produces a PreflightReport used by the GUI, CLI, and registry workflow.
All checks are deterministic and evidence-based.
"""
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from .tool import Tool
from .tool import Tool, validate_json_schema
@dataclass
@@ -36,8 +35,23 @@ class PreflightReport:
compatibility=self.compatibility + other.compatibility,
)
def to_dict(self) -> Dict[str, Any]:
return {
"errors": list(self.errors),
"warnings": list(self.warnings),
"suggestions": list(self.suggestions),
"similar_tools": list(self.similar_tools),
"generated_tests": list(self.generated_tests),
"compatibility": list(self.compatibility),
}
def analyze_tool(tool: Tool, registry_client=None) -> PreflightReport:
def analyze_tool(
tool: Tool,
registry_client=None,
*,
check_local_dependencies: bool = True,
) -> PreflightReport:
"""Run all preflight checks on a tool.
Args:
@@ -51,7 +65,8 @@ def analyze_tool(tool: Tool, registry_client=None) -> PreflightReport:
_check_config_integrity(tool, report)
_check_contracts(tool, report)
_check_secrets(tool, report)
_check_dependencies(tool, report)
if check_local_dependencies:
_check_dependencies(tool, report)
if registry_client:
_check_similar_tools(tool, report, registry_client)
return report
@@ -62,7 +77,7 @@ def _check_config_integrity(tool: Tool, report: PreflightReport):
report.errors.append("Tool name is required")
if tool.version and not _is_semver(tool.version):
report.errors.append(f"Version '{tool.version}' is not valid semver")
if not tool.steps and not tool.arguments:
if not tool.steps and not tool.arguments and tool.output == "{input}":
report.warnings.append(
"Tool has no steps and no arguments — output template is passthrough"
)
@@ -80,15 +95,10 @@ def _check_contracts(tool: Tool, report: PreflightReport):
def _validate_schema(schema: Any, label: str, report: PreflightReport):
if not isinstance(schema, dict):
report.errors.append(f"{label} must be a JSON Schema object")
return
schema_type = schema.get("type")
if schema_type and not isinstance(schema_type, (str, list)):
report.errors.append(f"{label} 'type' must be a string or array")
required = schema.get("required")
if required is not None and not isinstance(required, list):
report.errors.append(f"{label} 'required' must be an array")
try:
validate_json_schema(schema, label)
except ValueError as exc:
report.errors.append(str(exc))
def _check_secrets(tool: Tool, report: PreflightReport):
@@ -113,6 +123,8 @@ def _check_dependencies(tool: Tool, report: PreflightReport):
def _check_similar_tools(tool: Tool, report: PreflightReport, client):
from .registry_client import RegistryError
try:
results = client.search_tools(tool.name, per_page=5)
items = results.data if hasattr(results, "data") else results
@@ -124,7 +136,7 @@ def _check_similar_tools(tool: Tool, report: PreflightReport, client):
})
if not items:
report.suggestions.append("No similar tools found on the registry")
except Exception:
except RegistryError:
report.warnings.append("Could not search registry for similar tools")
+26
View File
@@ -2436,6 +2436,10 @@ def create_app() -> Flask:
data = yaml.safe_load(config_text) or {}
except yaml.YAMLError:
return error_response("VALIDATION_ERROR", "Invalid YAML in config")
if not isinstance(data, dict):
return error_response(
"VALIDATION_ERROR", "Tool config must contain a YAML mapping"
)
name = (data.get("name") or "").strip()
version = (data.get("version") or "").strip()
@@ -2485,6 +2489,26 @@ def create_app() -> Flask:
if len(str(tag)) > MAX_TAG_LEN:
return error_response("VALIDATION_ERROR", "Tag exceeds 32 characters")
# Run the same deterministic contract/configuration checks used by the
# CLI and builder. Registry hosts intentionally skip local-install
# dependency checks because publisher dependencies are resolved by name.
try:
from ..preflight import analyze_tool as run_preflight
from ..tool import Tool
preflight_report = run_preflight(
Tool.from_dict(data), check_local_dependencies=False
)
except (KeyError, TypeError, ValueError) as exc:
return error_response("INVALID_CONFIG", str(exc), 400)
if not preflight_report.ok:
return error_response(
"PREFLIGHT_FAILED",
preflight_report.errors[0],
400,
details={"preflight": preflight_report.to_dict()},
)
# Determine owner - admins can publish as "official" or other owners
owner = g.current_publisher["slug"]
requested_owner = payload.get("owner", "").strip()
@@ -2625,6 +2649,7 @@ def create_app() -> Flask:
"version": version,
"status": "validated",
"suggestions": suggestions,
"preflight": preflight_report.to_dict(),
}
})
@@ -2730,6 +2755,7 @@ def create_app() -> Flask:
"forked_from": forked_from,
"forked_version": forked_version,
"suggestions": suggestions,
"preflight": preflight_report.to_dict(),
}
})
response.status_code = 201
+32 -1
View File
@@ -44,6 +44,22 @@ def _validate_optional_patterns(patterns: Optional[List[str]], field_name: str)
raise ValueError(f"{field_name} must be a list of non-empty strings or null")
def validate_json_schema(schema: Optional[dict], field_name: str) -> None:
"""Validate an optional tool contract as a real JSON Schema."""
if schema is None:
return
if not isinstance(schema, dict):
raise ValueError(f"{field_name} must be a JSON Schema object")
from jsonschema.exceptions import SchemaError
from jsonschema.validators import validator_for
try:
validator_for(schema).check_schema(schema)
except SchemaError as exc:
raise ValueError(f"Invalid {field_name}: {exc.message}") from exc
@dataclass
class SystemDependency:
"""A system-level package dependency (apt, brew, pacman, etc.)."""
@@ -435,8 +451,15 @@ class Tool:
output_schema: Optional[dict] = None # JSON Schema for tool output contract
path: Optional[Path] = None # Path to config.yaml (set by load_tool)
def __post_init__(self) -> None:
validate_json_schema(self.input_schema, "input_schema")
validate_json_schema(self.output_schema, "output_schema")
@classmethod
def from_dict(cls, data: dict) -> "Tool":
if not isinstance(data, dict):
raise ValueError("Tool configuration must be a YAML mapping")
arguments = []
for arg in data.get("arguments", []):
arguments.append(ToolArgument.from_dict(arg))
@@ -455,7 +478,15 @@ class Tool:
# Parse source attribution if present
source = None
if "source" in data:
source = ToolSource.from_dict(data["source"])
source_data = data["source"]
if isinstance(source_data, dict):
source = ToolSource.from_dict(source_data)
elif isinstance(source_data, str) and source_data.strip():
source = ToolSource(
type="imported", original_tool=source_data.strip()
)
elif source_data is not None:
raise ValueError("source must be an attribution object or string")
# Normalize dependencies - can be strings or dicts with name/version
raw_deps = data.get("dependencies", [])