Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7f82a6e
feat(internal/cli/run_artifact_schema.go): add JSON schema validator …
dwin-gharibi Jul 13, 2026
43852be
test(internal/cli/run_artifact_schema_test.go): add tests for artifac…
dwin-gharibi Jul 13, 2026
3f8d048
feat(internal/cli/run.go): add --require-artifact-schema run validation
dwin-gharibi Jul 13, 2026
f4597d4
feat(internal/cli/timing.go): include schema validation results in ti…
dwin-gharibi Jul 13, 2026
34e32f1
docs(docs/features/artifacts.md): document --require-artifact-schema
dwin-gharibi Jul 13, 2026
f5744b0
fix(internal/cli/run_artifact_schema.go): fixing up the artifacts sch…
dwin-gharibi Jul 14, 2026
1b8e5a3
fix(internal/cli/run_artifact_schema_test.go): adding up more proper …
dwin-gharibi Jul 14, 2026
4929212
fix(docs/features/artifacts.md): updating up the docs accroding to la…
dwin-gharibi Jul 14, 2026
c7fea79
Merge branch 'main' into feat/require-artifact-schema
dwin-gharibi Jul 14, 2026
781dc31
Merge branch 'main' into feat/require-artifact-schema
dwin-gharibi Jul 16, 2026
a1487ab
Merge remote-tracking branch 'origin/main' into pr-1071
steipete Jul 17, 2026
248b5c1
fix(cli): harden artifact schema validation
steipete Jul 17, 2026
3ff3ca8
fix(cli): bound schema validation failures
steipete Jul 17, 2026
2fc14c9
Merge remote-tracking branch 'origin/main' into pr-1071-fix
steipete Jul 17, 2026
d04a148
fix(cli): reject schema aliases and invalid UTF-8
steipete Jul 17, 2026
66eed25
feat(cli): support standard artifact JSON Schemas
steipete Jul 17, 2026
4d42948
fix(cli): reject invalid UTF-8 artifacts
steipete Jul 17, 2026
b6a8308
Merge remote-tracking branch 'origin/main' into feat/require-artifact…
steipete Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 0.39.1 - Unreleased

### Added

- Added bounded JSON Schema validation for required run artifacts across supported standard drafts, with local references and redacted failure diagnostics. Thanks @dwin-gharibi.

## 0.39.0 - 2026-07-17

### Added
Expand Down
49 changes: 49 additions & 0 deletions docs/features/artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,55 @@ safety scanner. Keep required artifacts bounded and scrubbed, such as manifests,
summaries, screenshots, or QA reports. Do not use run artifacts for raw datasets,
secrets, credentials, signed URLs, or unredacted customer rows.

`--require-artifact-schema remote=schema.json` goes one step further than the
existence guard: after command success it fetches the named JSON artifact from
the lease and validates its content against a local schema file. The remote
artifact must be an exact safe relative path, not a glob. Validation fails the run
with `exit 7` when the artifact is missing, unparseable, oversized, or does not
match. Schemas use standard JSON Schema: draft 2020-12 when `$schema` is absent,
or draft 4, 6, 7, 2019-09, or 2020-12 when declared. Standard constraints such
as `pattern`, `minimum`, `additionalProperties`, composition keywords, and local
static acyclic `$ref`/`$defs` references are enforced. Reference cycles,
external schema references, and
runtime-rebound `$dynamicRef`/`$recursiveRef` references are rejected at
preflight so validation never performs an implicit fetch or escapes the static
work bound.
Schema files are bounded to 1 MiB, 4,096 JSON values, and 128 bytes per object
name; malformed, unreadable,
oversized, or invalid schemas fail fast at preflight with `exit 2`. A schema may
contain up to 2,048 subschemas across the raw and referenced compiled graph;
cardinality constraints may be at most
2,147,483,647. Schema resource IDs, reference keywords, and resolved URLs are
capped at 2 KiB each and 1 MiB in aggregate. The fetched artifact is bounded to 5 MiB; a larger artifact fails
the gate instead of being read into memory. Schemas and artifacts are capped at
64 levels of JSON nesting. JSON artifacts are also capped at 100,000 values, and
the product of artifact values and expanded compiled-schema validation weight
(including reference fanout, subschemas, required fields, dependencies, and
`uniqueItems` equality candidates) is capped at 100,000 work units. Repeated
schema work over artifact string values and object names is separately capped at
16 MiB of byte-work. JSON Schema regular expressions use a fail-closed,
linear-time ECMA-262 subset with translated dot, anchor, whitespace, control,
and Unicode-escape semantics; backreferences, lookarounds, inline options,
Unicode property escapes, and empty character classes are rejected. Sources are
capped at 64 KiB, compiled
programs at 100,000 work units each, and schema-wide compilation at 1,000,000
work units. The asserted `regex` format in drafts 4, 6, and 7 is rejected because
it would compile artifact-controlled expressions during validation; modern
format-annotation behavior remains available. Required/dependency names and
equality-candidate strings are charged by byte length. `uniqueItems` also charges worst-case structural, string, and numeric
comparison work for the largest artifact array. Numeric magnitude and
big-rational constraint work is capped at 16 MiB of numeric work, including
schema compilation. JSON numbers are capped at 1,024 characters and an absolute
exponent of 10,000. These limits bound compilation and validation before either
can build unbounded work. Failure diagnostics expose only JSON Pointer locations
and schema constraint locations, not rejected values; the empty root pointer is
displayed as `(root)`. Locations stop at 1 KiB,
the complete diagnostic set stops at 32 KiB, and at most 100 violations are
retained; control, line/paragraph separator, and bidirectional formatting characters are escaped. Each result is recorded on the timing report under
`schemaValidations`. The flag
is repeatable and, in this first phase, is supported on SSH-backed providers only;
delegated-run providers reject it until they expose an explicit capability.

Delegated providers reject run artifact collection until they grow an explicit
bounded artifact capability. Archive-capable adapters may validate and collect
required artifacts and artifact globs. Download-capable adapters may materialize
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (
github.com/containernetworking/cni v1.0.1
github.com/daytonaio/daytona/libs/api-client-go v0.183.0
github.com/daytonaio/daytona/libs/sdk-go v0.183.0
github.com/dlclark/regexp2 v1.12.0
github.com/firecracker-microvm/firecracker-go-sdk v1.0.0
github.com/gofrs/flock v0.13.0
github.com/google/go-tdx-guest v0.3.1
Expand All @@ -47,6 +48,10 @@ require (
nhooyr.io/websocket v1.8.17
)

require github.com/santhosh-tekuri/jsonschema/v6 v6.0.3-0.20260218184449-befd2c18b2f0

replace github.com/santhosh-tekuri/jsonschema/v6 => github.com/steipete/jsonschema/v6 v6.0.3-0.20260717053323-7235d3ee9642

require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8l
github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
Expand Down Expand Up @@ -881,6 +883,8 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8=
github.com/steipete/jsonschema/v6 v6.0.3-0.20260717053323-7235d3ee9642 h1:HBzDWz/MdDSutJjGP0hCU4q5hVzFXyoAafLaTsWdPBY=
github.com/steipete/jsonschema/v6 v6.0.3-0.20260717053323-7235d3ee9642/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand Down
26 changes: 26 additions & 0 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,14 @@ func (a App) runCommandWithBenchmarkRecord(ctx context.Context, args []string, b
var presetVars stringListFlag
var artifactGlobs stringListFlag
var requiredArtifactGlobs stringListFlag
var requiredArtifactSchemas stringListFlag
fs.Var(&downloads, "download", "download a remote file after command success: remote=local; repeatable")
fs.Var(&allowEnvFlags, "allow-env", "allow an environment variable for this run; repeatable or comma-separated")
fs.Var(&envProfileFlags, "env-from-profile", "load allowed environment values from a local profile file; repeatable")
fs.Var(&presetVars, "preset-var", "preset template variable name=value; repeatable or comma-separated")
fs.Var(&artifactGlobs, "artifact-glob", "collect remote files matching a safe glob into a local run artifact tarball; repeatable")
fs.Var(&requiredArtifactGlobs, "require-artifact", "require a remote file matching a safe glob after command success; repeatable")
fs.Var(&requiredArtifactSchemas, "require-artifact-schema", "validate a required artifact's JSON content against a schema file after command success: remote=schema.json; repeatable")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
timingJSON := fs.Bool("timing-json", false, "print final timing as JSON")
timingRecord := fs.String("timing-record", "", "append final timing to benchmark JSONL store: default, off, or path")
Expand Down Expand Up @@ -380,6 +382,11 @@ func (a App) runCommandWithBenchmarkRecord(ctx context.Context, args []string, b
if err := validateRequiredRunArtifactGlobs(requiredArtifactGlobs); err != nil {
return err
}
requiredArtifactSchemas = appendUniqueStrings(nil, requiredArtifactSchemas...)
loadedArtifactSchemas, err := loadRequireArtifactSchemas(requiredArtifactSchemas)
if err != nil {
return err
}
runArtifactGlobs := appendUniqueStrings(append([]string{}, expansion.ArtifactGlobs...), requiredArtifactGlobs...)
if *syncOnly {
if len(expansion.ArtifactGlobs) > 0 {
Expand All @@ -388,6 +395,9 @@ func (a App) runCommandWithBenchmarkRecord(ctx context.Context, args []string, b
if len(requiredArtifactGlobs) > 0 {
return exit(2, "--require-artifact cannot be combined with --sync-only")
}
if len(requiredArtifactSchemas) > 0 {
return exit(2, "--require-artifact-schema cannot be combined with --sync-only")
}
if strings.TrimSpace(*emitProof) != "" {
return exit(2, "--emit-proof cannot be combined with --sync-only")
}
Expand Down Expand Up @@ -650,6 +660,9 @@ func (a App) runCommandWithBenchmarkRecord(ctx context.Context, args []string, b
if strings.TrimSpace(*readyPool) != "" {
return exit(2, "--pool requires a brokered SSH lease provider")
}
if len(requiredArtifactSchemas) > 0 {
return exit(2, "--require-artifact-schema is not supported for provider=%s yet; use an SSH-backed provider", backend.Spec().Name)
}
if expansion.Profile.Doctor.Enabled {
return exit(2, "%s delegates run execution; profile doctor is not supported", backend.Spec().Name)
}
Expand Down Expand Up @@ -1617,6 +1630,7 @@ afterSync:
}
}
var artifactFailure error
var schemaValidationResults []SchemaValidationResult
if code == 0 && len(requiredArtifactGlobs) > 0 {
requireOutput, err := requireRunArtifactGlobs(ctx, target, workdir, requiredArtifactGlobs)
if err != nil {
Expand All @@ -1627,6 +1641,17 @@ afterSync:
fmt.Fprintln(a.Stderr, strings.TrimSpace(requireOutput))
}
}
if code == 0 && len(loadedArtifactSchemas) > 0 {
results, schemaOutput, schemaErr := validateRemoteArtifactSchemas(ctx, target, workdir, loadedArtifactSchemas)
schemaValidationResults = results
if strings.TrimSpace(schemaOutput) != "" {
fmt.Fprintln(a.Stderr, strings.TrimSpace(schemaOutput))
}
if schemaErr != nil {
artifactFailure = schemaErr
code = 7
}
}
if code == 0 {
for _, spec := range downloads {
bytes, local, err := downloadRemoteFile(ctx, target, workdir, spec)
Expand Down Expand Up @@ -1674,6 +1699,7 @@ afterSync:
report := timingReportFromRunWithActionsURL(cfg.Provider, leaseID, serverSlug(server), timings, total, code, actionsURL)
populateRunTimingMetadata(&report, cfg, repo, server, leaseID, recorder.runID, workdir, runArtifacts)
report.Label = runLabelValue
report.SchemaValidations = schemaValidationResults
if strings.TrimSpace(*emitProof) != "" && code == 0 {
template := cfg.ProofTemplates[strings.TrimSpace(*proofTemplate)]
proof, err := writeRunProof(strings.TrimSpace(*emitProof), strings.TrimSpace(*proofTemplate), proofRenderInput{
Expand Down
Loading
Loading