diff --git a/Makefile b/Makefile index 7b9b5d9..cfac289 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test validate validate-portable-ai dist release-dry-run clean +.PHONY: build test validate validate-portable-ai validate-model-carry-boundary dist release-dry-run clean BIN := sourceos-ai DIST_DIR := dist @@ -20,7 +20,10 @@ test: validate-portable-ai: python3 tools/validate_portable_ai_packs.py -validate: build validate-portable-ai +validate-model-carry-boundary: + python3 tools/validate_model_carry_authorization_boundaries.py + +validate: build validate-portable-ai validate-model-carry-boundary python3 tools/validate_carry_refs.py bin/$(BIN) carry validate --refs examples bin/$(BIN) validate --refs examples diff --git a/contracts/model-carry-authorization-boundary.schema.json b/contracts/model-carry-authorization-boundary.schema.json new file mode 100644 index 0000000..dfeb351 --- /dev/null +++ b/contracts/model-carry-authorization-boundary.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/model-carry/model-carry-authorization-boundary.schema.json", + "title": "SourceOS Model Carry Authorization Boundary", + "description": "Decision-only boundary proving a carry profile is a reference/profile object, not authorization for runtime execution, prompt egress, tool use, model download, training, promotion, or lifecycle mutation.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "kind", + "boundaryId", + "profileRef", + "profileKind", + "carryScope", + "authorizationBoundary", + "decisionRefs", + "evidenceRefs" + ], + "properties": { + "schemaVersion": { "const": "v0.1" }, + "kind": { "const": "ModelCarryAuthorizationBoundary" }, + "boundaryId": { "type": "string", "pattern": "^urn:srcos:model-carry-boundary:" }, + "profileRef": { "type": "string", "pattern": "^urn:srcos:model-profile:" }, + "profileKind": { "const": "LocalModelProfile" }, + "carryScope": { + "type": "object", + "additionalProperties": false, + "required": ["mayCarryProfile", "mayCarryServiceRef", "mayEmitEvidence", "mayRouteLocally"], + "properties": { + "mayCarryProfile": { "const": true }, + "mayCarryServiceRef": { "const": true }, + "mayEmitEvidence": { "const": true }, + "mayRouteLocally": { "type": "boolean" } + } + }, + "authorizationBoundary": { + "type": "object", + "additionalProperties": false, + "required": [ + "authorizesPromptEgress", + "authorizesNetworkAccess", + "authorizesToolUse", + "authorizesModelDownload", + "authorizesTrainingOnUserData", + "authorizesModelPromotion", + "authorizesLifecycleMutation" + ], + "properties": { + "authorizesPromptEgress": { "const": false }, + "authorizesNetworkAccess": { "const": false }, + "authorizesToolUse": { "const": false }, + "authorizesModelDownload": { "const": false }, + "authorizesTrainingOnUserData": { "const": false }, + "authorizesModelPromotion": { "const": false }, + "authorizesLifecycleMutation": { "const": false } + } + }, + "decisionRefs": { + "type": "object", + "additionalProperties": false, + "required": ["policyDecisionRefs", "routerDecisionRefs", "governanceLedgerRefs", "explicitPullRefs"], + "properties": { + "policyDecisionRefs": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "routerDecisionRefs": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "governanceLedgerRefs": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "explicitPullRefs": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + } + }, + "evidenceRefs": { "type": "array", "minItems": 1, "items": { "type": "string" }, "uniqueItems": true }, + "notes": { "type": "string" } + } +} diff --git a/docs/model-carry-authorization-boundary.md b/docs/model-carry-authorization-boundary.md new file mode 100644 index 0000000..2775fac --- /dev/null +++ b/docs/model-carry-authorization-boundary.md @@ -0,0 +1,45 @@ +# Model Carry Authorization Boundary + +## Purpose + +`ModelCarryAuthorizationBoundary` proves that a SourceOS local model profile is a carry-layer reference object, not an authorization object. + +The model carry repo may carry profiles, service refs, launch hints, cache policy, and evidence expectations. It does not authorize prompt egress, network access, tool use, model download, training on user data, model promotion, or model lifecycle mutation. + +## Boundary chain + +```text +local model profile = carry/reference object +model router = routing decision +policy fabric = prompt egress / network / tool-use admission +model governance ledger = lifecycle, tuning, promotion, consent, revocation evidence +explicit pull/install = separate operator action +``` + +## Required false authorizations + +A valid boundary record must set all of these to `false`: + +```text +authorizesPromptEgress +authorizesNetworkAccess +authorizesToolUse +authorizesModelDownload +authorizesTrainingOnUserData +authorizesModelPromotion +authorizesLifecycleMutation +``` + +## Validation + +```bash +python3 tools/validate_model_carry_authorization_boundaries.py +``` + +The validator checks one valid boundary fixture and negative fixtures for prompt egress and automatic model download. + +## Non-goals + +This tranche does not implement model execution, router decisions, model download, prompt egress, network access, tool access, personal tuning, or model promotion. + +It only hardens the carry-layer contract so future implementation work cannot treat a profile as authorization. diff --git a/examples/model-carry-authorization-boundary.download.invalid.json b/examples/model-carry-authorization-boundary.download.invalid.json new file mode 100644 index 0000000..1e73059 --- /dev/null +++ b/examples/model-carry-authorization-boundary.download.invalid.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "v0.1", + "kind": "ModelCarryAuthorizationBoundary", + "boundaryId": "urn:srcos:model-carry-boundary:invalid-download", + "profileRef": "urn:srcos:model-profile:local-llama32-1b", + "profileKind": "LocalModelProfile", + "carryScope": { + "mayCarryProfile": true, + "mayCarryServiceRef": true, + "mayEmitEvidence": true, + "mayRouteLocally": true + }, + "authorizationBoundary": { + "authorizesPromptEgress": false, + "authorizesNetworkAccess": false, + "authorizesToolUse": false, + "authorizesModelDownload": true, + "authorizesTrainingOnUserData": false, + "authorizesModelPromotion": false, + "authorizesLifecycleMutation": false + }, + "decisionRefs": { + "policyDecisionRefs": [], + "routerDecisionRefs": [], + "governanceLedgerRefs": [], + "explicitPullRefs": [] + }, + "evidenceRefs": ["evidence://sourceos-model-carry/invalid/model-download"], + "notes": "Invalid fixture: carry profile must not authorize model download." +} diff --git a/examples/model-carry-authorization-boundary.local-llama32-1b.json b/examples/model-carry-authorization-boundary.local-llama32-1b.json new file mode 100644 index 0000000..81ebba8 --- /dev/null +++ b/examples/model-carry-authorization-boundary.local-llama32-1b.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "v0.1", + "kind": "ModelCarryAuthorizationBoundary", + "boundaryId": "urn:srcos:model-carry-boundary:local-llama32-1b", + "profileRef": "urn:srcos:model-profile:local-llama32-1b", + "profileKind": "LocalModelProfile", + "carryScope": { + "mayCarryProfile": true, + "mayCarryServiceRef": true, + "mayEmitEvidence": true, + "mayRouteLocally": true + }, + "authorizationBoundary": { + "authorizesPromptEgress": false, + "authorizesNetworkAccess": false, + "authorizesToolUse": false, + "authorizesModelDownload": false, + "authorizesTrainingOnUserData": false, + "authorizesModelPromotion": false, + "authorizesLifecycleMutation": false + }, + "decisionRefs": { + "policyDecisionRefs": [], + "routerDecisionRefs": [], + "governanceLedgerRefs": [], + "explicitPullRefs": [] + }, + "evidenceRefs": ["evidence://sourceos-model-carry/local-llama32-1b/profile-boundary"], + "notes": "This record proves the carry profile may be carried and routed locally, but does not authorize prompt egress, network, tool use, download, training, promotion, or lifecycle mutation." +} diff --git a/examples/model-carry-authorization-boundary.prompt-egress.invalid.json b/examples/model-carry-authorization-boundary.prompt-egress.invalid.json new file mode 100644 index 0000000..ee49874 --- /dev/null +++ b/examples/model-carry-authorization-boundary.prompt-egress.invalid.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "v0.1", + "kind": "ModelCarryAuthorizationBoundary", + "boundaryId": "urn:srcos:model-carry-boundary:invalid-prompt-egress", + "profileRef": "urn:srcos:model-profile:local-llama32-1b", + "profileKind": "LocalModelProfile", + "carryScope": { + "mayCarryProfile": true, + "mayCarryServiceRef": true, + "mayEmitEvidence": true, + "mayRouteLocally": true + }, + "authorizationBoundary": { + "authorizesPromptEgress": true, + "authorizesNetworkAccess": false, + "authorizesToolUse": false, + "authorizesModelDownload": false, + "authorizesTrainingOnUserData": false, + "authorizesModelPromotion": false, + "authorizesLifecycleMutation": false + }, + "decisionRefs": { + "policyDecisionRefs": [], + "routerDecisionRefs": [], + "governanceLedgerRefs": [], + "explicitPullRefs": [] + }, + "evidenceRefs": ["evidence://sourceos-model-carry/invalid/prompt-egress"], + "notes": "Invalid fixture: carry profile must not authorize prompt egress." +} diff --git a/tools/validate_model_carry_authorization_boundaries.py b/tools/validate_model_carry_authorization_boundaries.py new file mode 100644 index 0000000..cabeedc --- /dev/null +++ b/tools/validate_model_carry_authorization_boundaries.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Validate SourceOS ModelCarryAuthorizationBoundary examples.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA = ROOT / "contracts" / "model-carry-authorization-boundary.schema.json" +VALID = ROOT / "examples" / "model-carry-authorization-boundary.local-llama32-1b.json" +INVALID_PROMPT_EGRESS = ROOT / "examples" / "model-carry-authorization-boundary.prompt-egress.invalid.json" +INVALID_DOWNLOAD = ROOT / "examples" / "model-carry-authorization-boundary.download.invalid.json" + +FORBIDDEN_AUTHORIZATIONS = ( + "authorizesPromptEgress", + "authorizesNetworkAccess", + "authorizesToolUse", + "authorizesModelDownload", + "authorizesTrainingOnUserData", + "authorizesModelPromotion", + "authorizesLifecycleMutation", +) + + +class ValidationError(Exception): + pass + + +def load_json(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValidationError(f"{path.relative_to(ROOT)}: expected JSON object") + return payload + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValidationError(message) + + +def validate_schema(schema: dict[str, Any]) -> None: + require(schema.get("$schema") == "https://json-schema.org/draft/2020-12/schema", "schema draft mismatch") + require(schema.get("type") == "object", "schema must describe object") + require(schema.get("additionalProperties") is False, "schema must be closed") + + +def validate_boundary(path: Path, record: dict[str, Any]) -> None: + require(record.get("schemaVersion") == "v0.1", f"{path}: schemaVersion must be v0.1") + require(record.get("kind") == "ModelCarryAuthorizationBoundary", f"{path}: kind mismatch") + require(str(record.get("boundaryId", "")).startswith("urn:srcos:model-carry-boundary:"), f"{path}: boundaryId must be SourceOS boundary URN") + require(str(record.get("profileRef", "")).startswith("urn:srcos:model-profile:"), f"{path}: profileRef must point at model profile") + require(record.get("profileKind") == "LocalModelProfile", f"{path}: profileKind must be LocalModelProfile") + + carry_scope = record.get("carryScope", {}) + require(carry_scope.get("mayCarryProfile") is True, f"{path}: mayCarryProfile must be true") + require(carry_scope.get("mayCarryServiceRef") is True, f"{path}: mayCarryServiceRef must be true") + require(carry_scope.get("mayEmitEvidence") is True, f"{path}: mayEmitEvidence must be true") + + auth = record.get("authorizationBoundary", {}) + for field in FORBIDDEN_AUTHORIZATIONS: + require(auth.get(field) is False, f"{path}: carry profile must not set {field}=true") + + evidence_refs = record.get("evidenceRefs", []) + require(isinstance(evidence_refs, list) and evidence_refs, f"{path}: evidenceRefs required") + for ref in evidence_refs: + require(isinstance(ref, str) and ref.startswith("evidence://"), f"{path}: evidenceRefs must use evidence:// refs") + + +def expect_invalid(path: Path) -> None: + try: + validate_boundary(path.relative_to(ROOT), load_json(path)) + except ValidationError: + return + raise ValidationError(f"invalid fixture unexpectedly validated: {path.relative_to(ROOT)}") + + +def main() -> int: + try: + validate_schema(load_json(SCHEMA)) + validate_boundary(VALID.relative_to(ROOT), load_json(VALID)) + expect_invalid(INVALID_PROMPT_EGRESS) + expect_invalid(INVALID_DOWNLOAD) + except (OSError, json.JSONDecodeError, ValidationError) as exc: + print(f"ERR: {exc}") + return 1 + print("Model carry authorization boundary validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())