diff --git a/Makefile b/Makefile index 51793fa..69edb56 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test validate validate-contracts validate-maturity validate-negative-fixtures dist release-dry-run clean +.PHONY: build test validate validate-contracts validate-maturity validate-negative-fixtures validate-wallguard-clean-room-synthesis dist release-dry-run clean BIN := holmes DIST_DIR := dist @@ -22,6 +22,10 @@ validate-maturity: validate-contracts: python3 tools/validate_holmes.py + python3 tools/validate_wallguard_clean_room_synthesis.py + +validate-wallguard-clean-room-synthesis: + python3 tools/validate_wallguard_clean_room_synthesis.py validate-negative-fixtures: python3 tools/run_negative_fixtures.py diff --git a/examples/wallguard-clean-room-synthesis.bad-clean-room.rejected.json b/examples/wallguard-clean-room-synthesis.bad-clean-room.rejected.json new file mode 100644 index 0000000..6b05791 --- /dev/null +++ b/examples/wallguard-clean-room-synthesis.bad-clean-room.rejected.json @@ -0,0 +1,24 @@ +{ + "apiVersion": "holmes.socioprophet.dev/v1", + "kind": "WallGuardCleanRoomSynthesis", + "metadata": { + "name": "wallguard-clean-room-synthesis-bad-clean-room-rejected", + "version": "0.1.0" + }, + "spec": { + "synthesisId": "holmes-synthesis://clean-room/invalid-001", + "sourceRetrievalRefs": ["sherlock-retrieval-filter://same-wall-pre-rank-001"], + "sourceWallRefs": ["wall://client-a/matter-x"], + "destinationRef": "workroom://firm/approved-knowledge", + "destinationWallRef": "none", + "wallDecisionRef": "wallguard-decision://clean-room-invalid-001", + "wallDecisionOutcome": "clean_room_release_allowed", + "synthesisDecision": "clean_room_release", + "outputClassification": "firm_approved", + "sourceLabelPreserved": true, + "restrictedPayloadExcluded": false, + "residualRestrictions": [], + "receiptRefs": ["wallguard-receipt://clean-room-invalid-001"], + "evidenceRefs": ["SocioProphet/sherlock-search#61", "SocioProphet/guardrail-fabric#32"] + } +} diff --git a/examples/wallguard-clean-room-synthesis.cross-wall.rejected.json b/examples/wallguard-clean-room-synthesis.cross-wall.rejected.json new file mode 100644 index 0000000..c931542 --- /dev/null +++ b/examples/wallguard-clean-room-synthesis.cross-wall.rejected.json @@ -0,0 +1,24 @@ +{ + "apiVersion": "holmes.socioprophet.dev/v1", + "kind": "WallGuardCleanRoomSynthesis", + "metadata": { + "name": "wallguard-clean-room-synthesis-cross-wall-rejected", + "version": "0.1.0" + }, + "spec": { + "synthesisId": "holmes-synthesis://cross-wall/invalid-001", + "sourceRetrievalRefs": ["sherlock-retrieval-filter://cross-wall-invalid-001"], + "sourceWallRefs": ["wall://client-a/matter-x", "wall://client-b/matter-y"], + "destinationRef": "workroom://client-a/matter-x", + "destinationWallRef": "wall://client-a/matter-x", + "wallDecisionRef": "wallguard-decision://cross-wall-synthesis-invalid-001", + "wallDecisionOutcome": "allow", + "synthesisDecision": "same_wall_synthesis", + "outputClassification": "firm_approved", + "sourceLabelPreserved": true, + "restrictedPayloadExcluded": false, + "residualRestrictions": [], + "receiptRefs": ["wallguard-receipt://cross-wall-synthesis-invalid-001"], + "evidenceRefs": ["SocioProphet/sherlock-search#61"] + } +} diff --git a/examples/wallguard-clean-room-synthesis.valid.json b/examples/wallguard-clean-room-synthesis.valid.json new file mode 100644 index 0000000..dc553c7 --- /dev/null +++ b/examples/wallguard-clean-room-synthesis.valid.json @@ -0,0 +1,24 @@ +{ + "apiVersion": "holmes.socioprophet.dev/v1", + "kind": "WallGuardCleanRoomSynthesis", + "metadata": { + "name": "wallguard-clean-room-synthesis-valid", + "version": "0.1.0" + }, + "spec": { + "synthesisId": "holmes-synthesis://client-a/matter-x/clean-room-001", + "sourceRetrievalRefs": ["sherlock-retrieval-filter://same-wall-pre-rank-001"], + "sourceWallRefs": ["wall://client-a/matter-x"], + "destinationRef": "workroom://client-a/matter-x", + "destinationWallRef": "wall://client-a/matter-x", + "wallDecisionRef": "wallguard-decision://same-wall-synthesis-allow-001", + "wallDecisionOutcome": "allow", + "synthesisDecision": "same_wall_synthesis", + "outputClassification": "wall_restricted", + "sourceLabelPreserved": true, + "restrictedPayloadExcluded": false, + "residualRestrictions": ["wall://client-a/matter-x"], + "receiptRefs": ["wallguard-receipt://same-wall-synthesis-allow-001"], + "evidenceRefs": ["SocioProphet/sherlock-search#61", "SocioProphet/memory-mesh#33"] + } +} diff --git a/schemas/wallguard-clean-room-synthesis.schema.json b/schemas/wallguard-clean-room-synthesis.schema.json new file mode 100644 index 0000000..a190296 --- /dev/null +++ b/schemas/wallguard-clean-room-synthesis.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.socioprophet.org/holmes/wallguard-clean-room-synthesis.schema.json", + "title": "WallGuard Clean Room Synthesis", + "type": "object", + "additionalProperties": false, + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "properties": { + "apiVersion": {"const": "holmes.socioprophet.dev/v1"}, + "kind": {"const": "WallGuardCleanRoomSynthesis"}, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "version": {"type": "string", "minLength": 1} + } + }, + "spec": { + "type": "object", + "additionalProperties": false, + "required": [ + "synthesisId", + "sourceRetrievalRefs", + "sourceWallRefs", + "destinationRef", + "destinationWallRef", + "wallDecisionRef", + "wallDecisionOutcome", + "synthesisDecision", + "outputClassification", + "sourceLabelPreserved", + "restrictedPayloadExcluded", + "residualRestrictions", + "receiptRefs", + "evidenceRefs" + ], + "properties": { + "synthesisId": {"type": "string", "minLength": 1}, + "sourceRetrievalRefs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "sourceWallRefs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "destinationRef": {"type": "string", "minLength": 1}, + "destinationWallRef": {"type": "string", "minLength": 1}, + "wallDecisionRef": {"type": "string", "minLength": 1}, + "wallDecisionOutcome": {"enum": ["allow", "deny", "redact", "quarantine", "escalate", "clean_room_release_requested", "clean_room_release_allowed", "clean_room_release_denied"]}, + "synthesisDecision": {"enum": ["same_wall_synthesis", "deny", "redact", "quarantine", "clean_room_release"]}, + "outputClassification": {"enum": ["wall_restricted", "clean_room_derived", "firm_approved", "public_sanitized", "quarantined"]}, + "sourceLabelPreserved": {"const": true}, + "restrictedPayloadExcluded": {"type": "boolean"}, + "residualRestrictions": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "receiptRefs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "evidenceRefs": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}, "uniqueItems": true} + } + } + } +} diff --git a/tools/validate_wallguard_clean_room_synthesis.py b/tools/validate_wallguard_clean_room_synthesis.py new file mode 100644 index 0000000..71880fa --- /dev/null +++ b/tools/validate_wallguard_clean_room_synthesis.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Validate WallGuard clean-room synthesis fixtures.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from jsonschema import Draft202012Validator + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA = ROOT / "schemas" / "wallguard-clean-room-synthesis.schema.json" +VALID = ROOT / "examples" / "wallguard-clean-room-synthesis.valid.json" +REJECTED = [ + ROOT / "examples" / "wallguard-clean-room-synthesis.cross-wall.rejected.json", + ROOT / "examples" / "wallguard-clean-room-synthesis.bad-clean-room.rejected.json", +] + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def validate_schema(instance: dict, schema: dict, *, source_label: str) -> None: + validator = Draft202012Validator(schema) + errors = sorted(validator.iter_errors(instance), key=lambda error: list(error.path)) + if errors: + lines = [f"{source_label} failed schema validation:"] + for error in errors: + location = ".".join(str(part) for part in error.path) or "" + lines.append(f" - {location}: {error.message}") + raise ValueError("\n".join(lines)) + + +def semantic_diagnostics(record: dict) -> list[str]: + diagnostics: list[str] = [] + spec = record["spec"] + source_walls = set(spec["sourceWallRefs"]) + destination_wall = spec["destinationWallRef"] + outcome = spec["wallDecisionOutcome"] + decision = spec["synthesisDecision"] + classification = spec["outputClassification"] + + if spec["sourceLabelPreserved"] is not True: + diagnostics.append("source labels must be preserved") + + if decision == "same_wall_synthesis": + if outcome != "allow": + diagnostics.append("same-wall synthesis requires WallGuard allow outcome") + if len(source_walls) != 1: + diagnostics.append("same-wall synthesis requires exactly one source wall") + if destination_wall not in source_walls: + diagnostics.append("same-wall synthesis destination wall must match source wall") + if classification != "wall_restricted": + diagnostics.append("same-wall synthesis output must remain wall_restricted") + if not spec["residualRestrictions"]: + diagnostics.append("same-wall synthesis requires residual restrictions") + + if decision == "clean_room_release": + if outcome != "clean_room_release_allowed": + diagnostics.append("clean-room release requires clean_room_release_allowed outcome") + if spec["restrictedPayloadExcluded"] is not True: + diagnostics.append("clean-room release must exclude restricted payload") + if classification not in {"clean_room_derived", "public_sanitized"}: + diagnostics.append("clean-room release output must be clean_room_derived or public_sanitized") + if not spec["residualRestrictions"]: + diagnostics.append("clean-room release must retain residual restrictions") + + if decision in {"deny", "quarantine"} and outcome == "allow": + diagnostics.append("deny/quarantine synthesis cannot use allow outcome") + + if classification in {"firm_approved", "public_sanitized"} and spec["restrictedPayloadExcluded"] is not True: + diagnostics.append("broader release classifications require restrictedPayloadExcluded=true") + + return diagnostics + + +def check(path: Path, schema: dict, expected: str) -> dict: + record = load_json(path) + validate_schema(record, schema, source_label=str(path.relative_to(ROOT))) + diagnostics = semantic_diagnostics(record) + actual = "fail" if diagnostics else "pass" + result = {"example": path.name, "expected": expected, "actual": actual, "diagnostics": diagnostics} + if actual != expected: + raise ValueError(json.dumps(result, indent=2)) + return result + + +def main() -> int: + schema = load_json(SCHEMA) + Draft202012Validator.check_schema(schema) + results = [check(VALID, schema, "pass")] + for path in REJECTED: + results.append(check(path, schema, "fail")) + print(json.dumps({"ok": True, "checked": results}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())