fix: harden workspace preprocessing contract

This commit is contained in:
2026-08-11 00:59:15 +02:00
parent 9df7c38069
commit 05a6e8cc2d
11 changed files with 494 additions and 32 deletions
@@ -404,7 +404,7 @@ func validateIngress(payload []byte, expected inputEnvelope) error {
var raw map[string]json.RawMessage
d := json.NewDecoder(bytes.NewReader(payload))
d.UseNumber()
if d.Decode(&raw) != nil {
if d.Decode(&raw) != nil || hasDuplicateJSONFields(payload) {
return errors.New("invalid workspace request")
}
var extra any
@@ -417,6 +417,12 @@ func validateIngress(payload []byte, expected inputEnvelope) error {
if dec.Decode(&supplied) != nil || supplied.SchemaVersion != 1 || supplied.Operation != expected.Operation || supplied.WorkspaceID != expected.WorkspaceID {
return errors.New("invalid workspace request")
}
// Presence is part of the command-derived envelope contract. Decoding into Go values
// alone would make an omitted field indistinguishable from an explicit zero/null value.
var generatedRaw map[string]json.RawMessage
if json.Unmarshal(mustJSON(expected), &generatedRaw) != nil || !sameJSONFieldSet(raw, generatedRaw) {
return errors.New("workspace request does not match command")
}
if !reflect.DeepEqual(supplied, expected) {
return errors.New("workspace request does not match command")
}
@@ -476,7 +482,7 @@ func Run(ctx context.Context, installation config.Installation, runner compose.R
}
var raw map[string]json.RawMessage
d := json.NewDecoder(strings.NewReader(cr.Stdout))
if d.Decode(&raw) != nil {
if d.Decode(&raw) != nil || hasDuplicateJSONFields([]byte(cr.Stdout)) {
return Result{}, errors.New("invalid workspace result")
}
var extra any
@@ -490,9 +496,20 @@ func Run(ctx context.Context, installation config.Installation, runner compose.R
if operationName(command) != "suggest-fks" {
return Result{}, errors.New("invalid workspace result")
}
if json.Unmarshal(h, &export) != nil {
var candidate hostExport
if hasDuplicateJSONFields(h) {
return Result{}, errors.New("invalid host export")
}
exportDecoder := json.NewDecoder(bytes.NewReader(h))
exportDecoder.DisallowUnknownFields()
if exportDecoder.Decode(&candidate) != nil {
return Result{}, errors.New("invalid host export")
}
var trailing any
if exportDecoder.Decode(&trailing) != io.EOF {
return Result{}, errors.New("invalid host export")
}
export = &candidate
delete(raw, "hostExport")
}
b, _ := json.Marshal(raw)
@@ -505,10 +522,13 @@ func Run(ctx context.Context, installation config.Installation, runner compose.R
if e = validateResult(result, env.WorkspaceID, operationName(command)); e != nil {
return Result{}, e
}
if !resultExitMatches(result.Status, cr.ExitCode) {
return Result{}, runErrOr(runErr, "workspace result status does not match child exit")
}
if runErr != nil {
if result.Status == "blocked" && cr.ExitCode == 3 {
} else if result.Status == "failed" && cr.ExitCode == 1 {
} else {
// Exit 1/3 are represented by the validated public result; callers still
// render it and select the corresponding process exit code.
if result.Status != "blocked" && result.Status != "failed" {
return Result{}, runErr
}
}
@@ -552,9 +572,6 @@ func candidateBoundToResult(x hostExport, result Result) bool {
}
func publishCandidate(x *hostExport, result Result, path string) error {
if path == "" {
return nil
}
if x.MediaType != "application/yaml" && x.MediaType != "text/yaml" {
return errors.New("invalid candidate export")
}
@@ -575,6 +592,9 @@ func publishCandidate(x *hostExport, result Result, path string) error {
if result.RunID == "" || !runIDPattern.MatchString(result.RunID) {
return errors.New("invalid candidate identity")
}
if path == "" {
return nil
}
if e = safeio.WriteCanonicalExclusive(path, b, 0o600); e != nil {
return errors.New("unsafe output file")
}
@@ -606,8 +626,89 @@ func validateResult(r Result, workspace, operation string) error {
if r.Status != "blocked" && r.Code == CodeRegistryBootstrapRecoveryConflict {
return errors.New("invalid workspace result")
}
if (r.Status == "succeeded" || r.Status == "unchanged" || r.Status == "dry_run") && r.Code != "ok" {
return errors.New("invalid workspace result")
}
if r.Status == "failed" && (r.Code == "ok" || r.Code == "manual_review_required" || r.Code == "evidence_materialization_required" || r.Code == "preprocessing_conflict" || r.Code == "preprocessing_resume_mismatch") {
return errors.New("invalid workspace result")
}
return nil
}
func resultExitMatches(status string, exitCode int) bool {
switch status {
case "succeeded", "unchanged", "dry_run":
return exitCode == 0
case "blocked":
return exitCode == 3
case "failed":
return exitCode == 1
default:
return false
}
}
func hasDuplicateJSONFields(payload []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.UseNumber()
if !scanJSONValue(decoder) {
return true
}
_, err := decoder.Token()
return err != io.EOF
}
func scanJSONValue(decoder *json.Decoder) bool {
token, err := decoder.Token()
if err != nil {
return false
}
delimiter, isDelimiter := token.(json.Delim)
if !isDelimiter {
return true
}
switch delimiter {
case '{':
seen := map[string]bool{}
for decoder.More() {
keyToken, keyErr := decoder.Token()
key, ok := keyToken.(string)
if keyErr != nil || !ok || seen[key] || !scanJSONValue(decoder) {
return false
}
seen[key] = true
}
_, err = decoder.Token()
return err == nil
case '[':
for decoder.More() {
if !scanJSONValue(decoder) {
return false
}
}
_, err = decoder.Token()
return err == nil
default:
return false
}
}
func sameJSONFieldSet(a, b map[string]json.RawMessage) bool {
if len(a) != len(b) {
return false
}
for key := range a {
if _, ok := b[key]; !ok {
return false
}
}
return true
}
func mustJSON(v any) []byte {
b, _ := json.Marshal(v)
return b
}
func DigestBytes(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) }
func validStatus(v string) bool {
switch v {