Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ Keep a Changelog and Stave uses Semantic Versioning.

## [Unreleased]

### Security

- Redact unknown and duplicate JSON-RPC envelope field names from protocol
errors so attacker-controlled keys do not enter client diagnostics.

## [1.0.0-rc.2] - 2026-09-10

### Security
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ GO_FILES := $(shell rg --files -g '*.go' .)
RIGOR := $(GO) run ./scripts/rigor/cmd/rigor

.PHONY: help tools fmt fmt-check lint vet test race coverage coverage-threshold \
fuzz-smoke benchmark-smoke verify-performance govulncheck dependency-inventory license-inventory \
fuzz-smoke fuzz-parser-long benchmark-smoke verify-performance govulncheck dependency-inventory license-inventory \
suppression-check \
api-refresh api-boundary traceability-refresh schema-freshness generated-refresh \
adapters conformance-check atlas-check workflow-validate hooks-install hooks-pre-commit-dry-run \
Expand All @@ -21,6 +21,7 @@ help:
'Available targets:' \
' make fast # local fast gate (fmt, lint, vet, API/schema checks)' \
' make verify # full local verification suite' \
' make fuzz-parser-long # run bounded config and agent-protocol fuzz targets' \
' make ci # canonical local CI entrypoint' \
' make generated-refresh # refresh tracked rigor inventories'

Expand Down Expand Up @@ -63,6 +64,9 @@ coverage-threshold: coverage
fuzz-smoke:
./scripts/rigor/run-fuzz-smoke.sh

fuzz-parser-long:
./scripts/rigor/run-parser-fuzz.sh

benchmark-smoke:
$(GO) test -run '^$$' -bench . -benchtime=1x ./...
$(GO) run ./cmd/stave-performance -samples=11
Expand Down
12 changes: 11 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func TestCanonicalRoundTripAndHashDeterminism(t *testing.T) {
cfg.Theme.ID = "brand"
cfg.Viewport = Viewport{Width: 100, Height: 40}
data := CanonicalJSON(cfg)
roundTrip, err := Parse(data)
roundTrip, err := decodeCanonicalConfig(data)
if err != nil {
t.Fatal(err)
}
Expand All @@ -144,6 +144,16 @@ func TestCanonicalRoundTripAndHashDeterminism(t *testing.T) {
}
}

func TestCanonicalV1Bytes(t *testing.T) {
cfg := Defaults()
cfg.Theme.ID = "brand"
cfg.Viewport = Viewport{Width: 100, Height: 40}
const want = `{"schemaVersion":"stave.config/v1","app":{},"theme":{"id":"brand","mode":"auto","density":"comfortable"},"viewport":{"width":100,"height":40},"capabilities":{"color":"auto","unicode":"auto","motion":"auto","mouse":"auto","alternateScreen":"auto"},"keymap":{},"runtime":{"mode":"auto","inputQueue":256,"actionQueue":64,"restoreOnPanic":true},"protocol":{"enabled":true,"transport":"stdio-jsonl","maxMessageBytes":4194304},"security":{"confirmationTTL":"60s","maxTreeNodes":100000},"diagnostics":{"level":"warn","format":"text"}}`
if got := string(CanonicalJSON(cfg)); got != want {
t.Fatalf("canonical v1 bytes changed:\nwant %s\n got %s", want, got)
}
}

func TestLayerHelpersRejectUnknownKeys(t *testing.T) {
if _, err := LayerFromEnv(map[string]string{"STAVE_UNKNOWN": "1"}); err == nil {
t.Fatal("expected env key rejection")
Expand Down
127 changes: 127 additions & 0 deletions config/fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package config

import (
"bytes"
"encoding/json"
"errors"
"io"
"strings"
"testing"
)

const maxConfigFuzzBytes = 64 << 10

func FuzzConfigParseCanonical(f *testing.F) {
for _, seed := range configFuzzSeeds() {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, input []byte) {
if !configFuzzInputInBounds(input) {
return
}
config, err := Parse(input)
if err != nil {
assertConfigParseErrorSafe(t, err)
return
}
assertConfigCanonicalRoundTrip(t, config)
})
}

func configFuzzInputInBounds(input []byte) bool {
if len(input) > maxConfigFuzzBytes {
return false
}
depth, valid := jsonNesting(input)
return !valid || depth <= 64
}

func assertConfigParseErrorSafe(t *testing.T, err error) {
t.Helper()
if strings.Contains(err.Error(), "fuzz-secret") {
t.Fatalf("config parser echoed a secret: %v", err)
}
}

func assertConfigCanonicalRoundTrip(t *testing.T, config Config) {
t.Helper()
canonical := CanonicalJSON(config)
roundTrip, err := decodeCanonicalConfig(canonical)
if err != nil {
t.Fatalf("accepted config did not round trip: %v", err)
}
if !bytes.Equal(canonical, CanonicalJSON(roundTrip)) {
t.Fatalf("accepted config canonical encoding was unstable")
}
if HashString(config) != HashString(roundTrip) {
t.Fatalf("accepted config canonical hash was unstable")
}
}

// decodeCanonicalConfig decodes a resolved Config. Parse consumes sparse
// layers and applies Defaults, so it is intentionally not this decoder.
func decodeCanonicalConfig(data []byte) (Config, error) {
var config Config
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&config); err != nil {
return Config{}, err
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return Config{}, errors.New("canonical config has trailing JSON")
}
if err := Validate(config); err != nil {
return Config{}, err
}
return config, nil
}

func configFuzzSeeds() [][]byte {
valid := []byte(`{"schemaVersion":"stave.config/v1","theme":{"mode":"dark"},"protocol":{"maxMessageBytes":4194304}}`)
nested := []byte(`{"schemaVersion":"stave.config/v1","theme":{"mode":{"nested":true}}}`)
limit := append([]byte(`{"schemaVersion":"stave.config/v1"}`), bytes.Repeat([]byte(" "), maxConfigFuzzBytes-len(`{"schemaVersion":"stave.config/v1"}`))...)
return [][]byte{
valid,
[]byte(`{"schemaVersion":"stave.config/v1","theme":{"mode":"dark","unknown":true}}`),
[]byte(`{"schemaVersion":"stave.config/v1","schemaVersion":"stave.config/v1"}`),
[]byte(`{"schemaVersion":"stave.config/v1"} {}`),
[]byte(`null`),
[]byte(`{"schemaVersion":"stave.config/v1","app":{"id":"fuzz-secret"},"unknown":true}`),
[]byte(`{"schemaVersion":"stave.config/v1","protocol":{"enabled":false}}`),
[]byte(`{"schemaVersion":"stave.config/v1","runtime":{"restoreOnPanic":false}}`),
[]byte(`{"schemaVersion":"stave.config/v1","app":{"id":"brace { in a string }"}}`),
[]byte("\xff{"),
nested,
limit,
append(append([]byte(nil), limit...), 'x'),
}
}

// jsonNesting counts JSON delimiters through tokens so braces in strings do
// not exclude valid seeds from the bounded fuzz domain.
func jsonNesting(input []byte) (maximum int, valid bool) {
decoder := json.NewDecoder(bytes.NewReader(input))
depth := 0
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
return maximum, true
}
if err != nil {
return 0, false
}
switch delimiter := token.(type) {
case json.Delim:
switch delimiter {
case '{', '[':
depth++
if depth > maximum {
maximum = depth
}
case '}', ']':
depth--
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("{\"sChemAVersion\":\"\",\"theme\":{\"mode\":\"\"}}")
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ same semantic interface for people and automation.
- [Adopt Stave in an application](client-adoption.md)
- [Use Stave UI primitives](primitives.md)
- [Meet accessibility and agent-control expectations](accessibility-agent-parity.md)
- [Run bounded parser fuzzing](fuzzing.md)
- [Understand compatibility guarantees](compatibility.md)
- [Apply the security contract](security.md)
- [Publish a release candidate](releasing.md)
Expand Down
28 changes: 28 additions & 0 deletions docs/fuzzing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Parser fuzzing

`make fuzz-smoke` discovers every repository fuzz target and runs one bounded
iteration. It includes configuration parsing/canonicalization and JSON-RPC
request framing. The parser targets seed duplicate and unknown fields, trailing
and null input, malformed UTF-8 and JSON, nesting boundaries, and exact input
size boundaries.

For a separately chosen, bounded parser run, use a local checkout:

```sh
STAVE_PARSER_FUZZ_TIME=30s make fuzz-parser-long
```

The command first verifies that both required parser fuzz targets are present.
`config.Parse` consumes a sparse layer and applies defaults. Canonical output
represents a resolved `Config`, so the configuration target decodes it into a
zero `Config` with strict JSON checks and then validates it before comparing
canonical bytes and hashes. Accepted configuration and request values must
round-trip canonically; invalid input must be bounded and must not echo the
seeded secret value in configuration or protocol errors. The configuration input-size and
nesting bounds belong to the fuzz harness; its nesting guard uses JSON tokens
so braces in strings remain covered. The protocol parser separately enforces
the caller-provided byte cap and its production depth-64 guard. Protocol
round-trip checks allow the emitted JSON length because escaping can expand
an accepted input.
A completed fuzz run exercises only the chosen time budget. It does not claim
that the library has no vulnerabilities.
107 changes: 107 additions & 0 deletions protocol/fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package protocol

import (
"bytes"
"encoding/json"
"io"
"reflect"
"strings"
"testing"
)

const maxProtocolFuzzBytes = 4 << 10

func FuzzDecodeLineBounded(f *testing.F) {
for _, seed := range protocolFuzzSeeds() {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, input []byte) {
if len(input) > maxProtocolFuzzBytes {
if _, err := DecodeLine(input, maxProtocolFuzzBytes); err == nil {
t.Fatal("oversized protocol input was accepted")
}
return
}
request, err := DecodeLine(input, maxProtocolFuzzBytes)
if err != nil {
if strings.Contains(err.Error(), "fuzz-secret") {
t.Fatalf("protocol parser echoed a secret: %v", err)
}
return
}
canonical, err := json.Marshal(request)
if err != nil {
t.Fatalf("accepted request did not marshal: %v", err)
}
roundTrip, err := DecodeLine(canonical, len(canonical))
if err != nil {
t.Fatalf("accepted request did not round trip: %v", err)
}
if !semanticJSONEqual(request.ID, roundTrip.ID) || request.JSONRPC != roundTrip.JSONRPC || request.Method != roundTrip.Method || !semanticJSONEqual(request.Params, roundTrip.Params) {
t.Fatalf("accepted request changed during round trip")
}
})
}

func protocolFuzzSeeds() [][]byte {
valid := []byte(`{"jsonrpc":"2.0","id":"0007","method":"stave.ping","params":{"value":true}}`)
limit := append(append([]byte(nil), valid...), bytes.Repeat([]byte(" "), maxProtocolFuzzBytes-len(valid))...)
return [][]byte{
valid,
[]byte(`{"jsonrpc":"2.0","id":1,"method":"` + strings.Repeat("&", 680) + `"}`),
[]byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","unexpected":true}`),
[]byte(`{"jsonrpc":"2.0","id":1,"fuzz-secret":1,"fuzz-secret":2,"method":"stave.ping"}`),
[]byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping"} {}`),
[]byte(`{"jsonrpc":"2.0", "id": "0007", "method": "stave.ping", "params": { "value": true } }`),
[]byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","params":{"token":"fuzz-secret"},"unexpected":true}`),
[]byte(`null`),
[]byte("\xff{"),
protocolNestedSeed(64),
protocolNestedSeed(65),
limit,
append(append([]byte(nil), limit...), 'x'),
}
}

func semanticJSONEqual(left, right []byte) bool {
leftValue, leftOK := decodeJSONValue(left)
rightValue, rightOK := decodeJSONValue(right)
return leftOK && rightOK && reflect.DeepEqual(leftValue, rightValue)
}

func decodeJSONValue(value []byte) (any, bool) {
if len(value) == 0 {
return nil, true
}
decoder := json.NewDecoder(bytes.NewReader(value))
decoder.UseNumber()
var decoded any
if err := decoder.Decode(&decoded); err != nil {
return nil, false
}
if decoder.Decode(new(any)) != io.EOF {
return nil, false
}
return decoded, true
}

func protocolNestedSeed(depth int) []byte {
output := []byte(`{"jsonrpc":"2.0","id":1,"method":"stave.ping","params":`)
for range depth {
output = append(output, []byte(`{"nested":`)...)
}
output = append(output, []byte(`null`)...)
for range depth {
output = append(output, '}')
}
return append(output, '}')
}

func TestProtocolFuzzNestingBoundarySeeds(t *testing.T) {
if _, err := DecodeLine(protocolNestedSeed(64), maxProtocolFuzzBytes); err != nil {
t.Fatalf("accepted boundary seed rejected: %v", err)
}
if _, err := DecodeLine(protocolNestedSeed(65), maxProtocolFuzzBytes); err == nil {
t.Fatal("over-boundary seed accepted")
}
}
6 changes: 5 additions & 1 deletion protocol/jsonl.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"strings"
"unicode/utf8"
)

Expand Down Expand Up @@ -37,6 +38,9 @@ func DecodeLine(line []byte, max int) (Request, error) {
dec.UseNumber()
dec.DisallowUnknownFields()
if err := dec.Decode(&r); err != nil {
if strings.HasPrefix(err.Error(), "json: unknown field ") {
return Request{}, errors.New("unknown JSON-RPC envelope field")
}
return Request{}, err
}
if err := ensureEOF(dec); err != nil {
Expand Down Expand Up @@ -85,7 +89,7 @@ func validateObject(b []byte, depth int) error {
return errors.New("invalid object key")
}
if _, exists := seen[key]; exists {
return fmt.Errorf("duplicate key %q", key)
return errors.New("duplicate object key")
}
seen[key] = struct{}{}
var raw json.RawMessage
Expand Down
Loading
Loading