From ac1e150168a9a7c00421af28c8499b99047c427f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 5 Nov 2026 15:59:30 +0000 Subject: [PATCH] Validate schemas without relying on source filenames --- docs/formats.md | 2 + docs/roadmap/remediation.md | 7 ++- internal/validate/schema_portability_test.go | 46 ++++++++++++++++++++ internal/validate/standard_validator.go | 14 ++++++ schema_file_contract_test.go | 18 ++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 internal/validate/schema_portability_test.go diff --git a/docs/formats.md b/docs/formats.md index c173467..1293d4c 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -421,6 +421,8 @@ Schemas are JSON documents selected by a prompt or request `schema_path`. Directory and `fs.FS` schema paths resolve within the configured root. Each schema must be self-contained: in-document references, IDs, and anchors are supported, but references requiring another file or network resource fail. +References must also work without the source filename as a base; use fragment +references or explicit schema IDs rather than relying on that filename. `WithSchemaFile` exposes one schema under its base name. An unreadable, invalid, or unresolvable schema causes an operational validation error. Valid JSON that fails the schema instead yields a failed validation result. diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 8658a85..b6c21a0 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -72,7 +72,12 @@ existing cancellation policy. ### 2. Check Schema Self-Containment Independently Of Its Source Filename [P2] -**Status:** Open. **Stages:** 11–13. +**Status:** Complete. **Stages:** 11–13. + +Completion: shared compilation checks both the source base and an unrelated +submission base through the same compiler rules, including unused declarations. +Host and filesystem regressions preserve fragment, explicit-ID, and embedded-ID +references while rejecting unsent source-name dependencies. **Locations:** [schema compilation](../../internal/validate/standard_validator.go), `compileSchemaDocument`, lines 426–445, and its host/`fs.FS` callers; diff --git a/internal/validate/schema_portability_test.go b/internal/validate/schema_portability_test.go new file mode 100644 index 0000000..dd7b8a5 --- /dev/null +++ b/internal/validate/schema_portability_test.go @@ -0,0 +1,46 @@ +package validate + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "go.maximumdirect.net/promptkit/internal/domain" +) + +func TestSchemaPreparationDoesNotDependOnSourceName(t *testing.T) { + for _, test := range []struct { + name, schema string + rejected bool + }{ + {"source name", `{"$defs":{"v":{"type":"integer"}},"$ref":"schema.json#/$defs/v"}`, true}, + {"unused source name", `{"$defs":{"v":{"$ref":"schema.json"}}}`, true}, + {"fragment", `{"$defs":{"v":{"type":"integer"}},"$ref":"#/$defs/v"}`, false}, + {"explicit ID", `{"$id":"https://example.invalid/schema.json","$defs":{"v":{"type":"integer"}},"$ref":"schema.json#/$defs/v"}`, false}, + {"embedded ID", `{"$defs":{"v":{"$id":"child","type":"integer"}},"$ref":"child"}`, false}, + } { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "schema.json"), []byte(test.schema), 0600); err != nil { + t.Fatal(err) + } + fsys := fstest.MapFS{"schema.json": &fstest.MapFile{Data: []byte(test.schema)}} + for i, validator := range []ValidationPreparer{NewStandardValidator(dir), NewFSValidator(fsys, ".")} { + t.Run(fmt.Sprint(i), func(t *testing.T) { + _, err := validator.PrepareValidation(context.Background(), domain.OutputContract{Format: domain.FormatJSON, ValidationMode: domain.ValidationJSONSchema, SchemaPath: "schema.json"}) + if test.rejected { + if err == nil || !strings.Contains(err.Error(), "source location") { + t.Fatalf("expected source-independent rejection, got %v", err) + } + } else if err != nil { + t.Fatal(err) + } + }) + } + }) + } +} diff --git a/internal/validate/standard_validator.go b/internal/validate/standard_validator.go index f2b1cf9..bc87387 100644 --- a/internal/validate/standard_validator.go +++ b/internal/validate/standard_validator.go @@ -424,6 +424,20 @@ func (l externalSchemaLoader) Load(resourceURL string) (any, error) { } func compileSchemaDocument(ctx context.Context, resourceURL string, document any) (*jsonschema.Schema, error) { + root, err := compileSchemaAt(ctx, resourceURL, document) + if err != nil { + return nil, err + } + // File and private-FS retrieval URLs are never available to the provider. + // Compiling again under an unrelated HTTPS base detects reliance on those + // aliases without rewriting references or duplicating URI resolution rules. + if _, err := compileSchemaAt(ctx, "https://promptkit.invalid/submitted-schema", document); err != nil { + return nil, fmt.Errorf("schema is not independent of its source location; use fragment references or explicit schema IDs: %w", err) + } + return root, nil +} + +func compileSchemaAt(ctx context.Context, resourceURL string, document any) (*jsonschema.Schema, error) { if err := ctx.Err(); err != nil { return nil, err } diff --git a/schema_file_contract_test.go b/schema_file_contract_test.go index c221918..ad336da 100644 --- a/schema_file_contract_test.go +++ b/schema_file_contract_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "os" @@ -11,10 +12,17 @@ import ( "strings" "testing" + "github.com/santhosh-tekuri/jsonschema/v6" "go.maximumdirect.net/configkit" "go.maximumdirect.net/promptkit" ) +type rejectWireSchemaLoad struct{} + +func (rejectWireSchemaLoad) Load(uri string) (any, error) { + return nil, fmt.Errorf("unexpected external resource %s", uri) +} + func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) { firstDir, secondDir := t.TempDir(), t.TempDir() first, second := filepath.Join(firstDir, "schema.json"), filepath.Join(secondDir, "schema.json") @@ -57,6 +65,16 @@ func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) { if err != nil || !strings.Contains(string(encoded), `"$defs"`) || !strings.Contains(string(encoded), `#/$defs/value`) { t.Fatalf("wire schema = %s, %v", encoded, err) } + compiler := jsonschema.NewCompiler() + compiler.UseLoader(rejectWireSchemaLoad{}) + const submission = "https://provider.invalid/request-schema" + document := payload["response_format"].(map[string]any)["json_schema"].(map[string]any)["schema"] + if err := compiler.AddResource(submission, document); err != nil { + t.Fatal(err) + } + if _, err := compiler.Compile(submission); err != nil { + t.Fatalf("outbound schema needs local source context: %v", err) + } if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrValidation) { t.Fatalf("missing root error = %v", err) }