fix(thothctl): harden workspace result and candidate publication

This commit is contained in:
2026-08-11 03:39:48 +02:00
parent ad80180381
commit fcc45520ad
8 changed files with 459 additions and 53 deletions
@@ -458,7 +458,19 @@ func validateIngress(payload []byte, expected inputEnvelope) error {
return nil
}
// ResultProjector is an optional host-side projection applied to the validated
// child result before any host-side candidate is published. It is deliberately
// a callback so workspaceops does not depend on the CLI's redaction policy.
type ResultProjector func(Result) (Result, error)
// Run preserves the original API for callers that do not need a public projection.
func Run(ctx context.Context, installation config.Installation, runner compose.Runner, command Command, stdin io.Reader) (Result, error) {
return RunWithProjector(ctx, installation, runner, command, stdin, nil)
}
// RunWithProjector validates the optional projected envelope before publishing
// any host export. This ordering is part of the workspace boundary contract.
func RunWithProjector(ctx context.Context, installation config.Installation, runner compose.Runner, command Command, stdin io.Reader, projector ResultProjector) (Result, error) {
env, generated, e := makeInput(command)
if e != nil {
return Result{}, e
@@ -536,6 +548,13 @@ func Run(ctx context.Context, installation config.Installation, runner compose.R
return Result{}, runErr
}
}
if projector != nil {
projected, projectErr := projector(result)
if projectErr != nil || validateResult(projected, env.WorkspaceID, operationName(command)) != nil {
return Result{}, errors.New("invalid workspace result")
}
result = projected
}
if hasExport {
if export == nil {
return Result{}, errors.New("invalid candidate export")
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -616,3 +617,38 @@ func TestRunRejectsTrailingOrUnknownResultEnvelope(t *testing.T) {
})
}
}
func TestRunProjectsBeforePublishingCandidate(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
candidate := []byte("candidates: []\n")
digest := DigestBytes(candidate)
result := validWorkspaceResult("succeeded", "ok")
result.Operation = "suggest-fks"
result.RunID = strings.Repeat("b", 32)
result.ArtifactIdentities = []ArtifactIdentity{{Kind: "fk-candidates", Digest: digest}}
envelope := map[string]any{"schemaVersion": result.SchemaVersion, "status": result.Status, "code": result.Code,
"workspaceId": result.WorkspaceID, "workspaceRevision": result.WorkspaceRevision, "descriptorBlob": result.DescriptorBlob,
"operation": result.Operation, "runId": result.RunID, "completedStages": []string{"safe\nforged"},
"artifactIdentities": result.ArtifactIdentities,
"hostExport": hostExport{MediaType: "application/yaml", SHA256: digest, ContentBase64: base64.StdEncoding.EncodeToString(candidate)}}
payload, err := json.Marshal(envelope)
if err != nil {
t.Fatal(err)
}
fake := filepath.Join(root, "docker")
if err := os.WriteFile(fake, []byte("#!/bin/sh\ncat >/dev/null\nprintf '%s' '"+string(payload)+"'\n"), 0o700); err != nil {
t.Fatal(err)
}
output := filepath.Join(root, "candidate.yaml")
_, err = RunWithProjector(context.Background(), config.Installation{ProjectDirectory: root}, compose.NewRunner(fake), SuggestFksRequest{WorkspaceID: "psd", Output: output}, nil,
func(result Result) (Result, error) { return Result{}, errors.New("unsafe projected result") })
if err == nil {
t.Fatal("accepted a rejected public projection")
}
if _, statErr := os.Stat(output); !os.IsNotExist(statErr) {
t.Fatalf("candidate was published before projection rejection: %v", statErr)
}
}