fix workspace output and cleanup uncertainty
This commit is contained in:
@@ -69,6 +69,7 @@ func main() {
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
isWorkspaceCommand := len(args) > 2 && args[2] == "workspace"
|
||||
// Workspace results are untrusted child output, so only that dispatch receives
|
||||
// the public bounds. Legacy renderers intentionally retain their established
|
||||
// behavior and must not silently truncate successful output.
|
||||
@@ -82,21 +83,33 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
installationPath, command, commandArgs, err := parseArgs(args)
|
||||
if err != nil {
|
||||
if isWorkspaceCommand {
|
||||
return writeWorkspaceError(stderr, err, nil, 2)
|
||||
}
|
||||
fmt.Fprintf(stderr, "thothctl: %s\n\n%s", err, usage)
|
||||
return 2
|
||||
}
|
||||
installation, err := config.Load(installationPath)
|
||||
if err != nil {
|
||||
if isWorkspaceCommand {
|
||||
return writeWorkspaceError(stderr, err, nil, 2)
|
||||
}
|
||||
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(err.Error(), nil))
|
||||
return 2
|
||||
}
|
||||
secretFiles, err := installation.SecretFiles()
|
||||
if err != nil {
|
||||
if isWorkspaceCommand {
|
||||
return writeWorkspaceError(stderr, errors.New("installation secret declarations could not be read"), nil, 2)
|
||||
}
|
||||
fmt.Fprintln(stderr, "thothctl: installation secret declarations could not be read")
|
||||
return 2
|
||||
}
|
||||
secretValues, err := output.SecretValuesFromFiles(secretFiles)
|
||||
if err != nil {
|
||||
if isWorkspaceCommand {
|
||||
return writeWorkspaceError(stderr, errors.New("declared secret file could not be read"), nil, 2)
|
||||
}
|
||||
fmt.Fprintln(stderr, "thothctl: declared secret file could not be read")
|
||||
return 2
|
||||
}
|
||||
@@ -105,7 +118,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if command == "workspace" {
|
||||
workspaceCommand, parseErr := workspaceops.ParseWorkspaceCommand(append([]string{"workspace"}, commandArgs...))
|
||||
if parseErr != nil {
|
||||
return commandUsageError(stderr, parseErr.Error())
|
||||
return writeWorkspaceError(stderr, parseErr, secretValues, 2)
|
||||
}
|
||||
jsonMode := true
|
||||
switch c := workspaceCommand.(type) {
|
||||
@@ -134,21 +147,31 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
} else {
|
||||
finalOutput, err = encodeWorkspaceHuman(result)
|
||||
}
|
||||
if err != nil || len(finalOutput) > maxPublicStdoutBytes {
|
||||
if err != nil {
|
||||
return errors.New("workspace output could not be encoded")
|
||||
}
|
||||
if len(finalOutput) > maxPublicStdoutBytes {
|
||||
return errors.New("workspace result exceeds output limit")
|
||||
}
|
||||
// This is the exact once-encoded byte slice that will be published.
|
||||
// Scan it after encoding so JSON keys and human renderer chrome are
|
||||
// inside the same no-secret boundary as typed result values.
|
||||
if containsDeclaredSecretBytes(finalOutput, secretValues) {
|
||||
return errors.New("workspace output contains a declared secret")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if operationErr != nil {
|
||||
if workspaceUsageError(operationErr) {
|
||||
return commandUsageError(stderr, operationErr.Error())
|
||||
return writeWorkspaceError(stderr, operationErr, secretValues, 2)
|
||||
}
|
||||
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(operationErr.Error(), secretValues))
|
||||
return 1
|
||||
return writeWorkspaceError(stderr, operationErr, secretValues, 1)
|
||||
}
|
||||
if _, err := stdout.Write(finalOutput); err != nil {
|
||||
fmt.Fprintln(stderr, "thothctl: workspace result exceeds output limit")
|
||||
return 1
|
||||
// The candidate publication precedes stdout. A physical stdout
|
||||
// failure therefore requires reconciliation before retrying; it is
|
||||
// not an output-limit rejection.
|
||||
return writeWorkspaceError(stderr, errors.New("workspace output write failed; reconcile any committed candidate before retrying"), secretValues, 1)
|
||||
}
|
||||
if result.Status == "blocked" {
|
||||
return 3
|
||||
@@ -270,6 +293,37 @@ func (w *boundedWriter) Write(p []byte) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
func containsDeclaredSecretBytes(contents []byte, secrets []string) bool {
|
||||
for _, secret := range secrets {
|
||||
if secret != "" && bytes.Contains(contents, []byte(secret)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writeWorkspaceError is the sole workspace stderr path. It validates the
|
||||
// complete rendered line, including its fixed prefix, before writing; if no
|
||||
// deterministic safe line exists it emits empty stderr rather than risk a
|
||||
// declared-secret collision.
|
||||
func writeWorkspaceError(stderr io.Writer, err error, secrets []string, code int) int {
|
||||
message := "workspace operation failed"
|
||||
if err != nil {
|
||||
message = output.Sanitize(err.Error(), secrets)
|
||||
}
|
||||
candidates := [][]byte{[]byte("workspace operation failed\n")}
|
||||
if secrets != nil {
|
||||
candidates = append([][]byte{[]byte("thothctl: " + message + "\n")}, candidates...)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if !containsDeclaredSecretBytes(candidate, secrets) {
|
||||
_, _ = stderr.Write(candidate)
|
||||
return code
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func encodeWorkspaceJSON(result workspaceops.Result) ([]byte, error) {
|
||||
var encoded bytes.Buffer
|
||||
encoder := json.NewEncoder(&encoded)
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -134,12 +136,21 @@ func TestRunWorkspacePublicDispatchExitMatrix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunWorkspaceBoundsFinalJSONEncoding(t *testing.T) {
|
||||
for _, tc := range []struct{ name string; final int; wantCode int }{{"exact", 1 << 20, 0}, {"one-over", (1 << 20) + 1, 1}} {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
final int
|
||||
wantCode int
|
||||
}{
|
||||
{name: "exact", final: 1 << 20, wantCode: 0},
|
||||
{name: "one-over", final: (1 << 20) + 1, wantCode: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
payload, encodedLength := boundedWorkspaceResultPayload(t, tc.final)
|
||||
if encodedLength != tc.final { t.Fatalf("final encoded length = %d, want %d", encodedLength, tc.final) }
|
||||
if encodedLength != tc.final {
|
||||
t.Fatalf("final encoded length = %d, want %d", encodedLength, tc.final)
|
||||
}
|
||||
writeWorkspaceResultFile(t, fixture, payload)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr)
|
||||
@@ -150,6 +161,78 @@ func TestRunWorkspaceBoundsFinalJSONEncoding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type failingWorkspaceWriter struct{}
|
||||
|
||||
func (failingWorkspaceWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("injected stdout failure")
|
||||
}
|
||||
|
||||
func TestRunWorkspaceReportsCommittedReconcileOnStdoutFailure(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"0000000000000000000000000000000000000000","descriptorBlob":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation":"inspect","completedStages":[]}`)
|
||||
var stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, failingWorkspaceWriter{}, &stderr)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "reconcile") {
|
||||
t.Fatalf("exit=%d stderr=%q, want committed reconcile guidance", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceRejectsDeclaredSecretInFinalJSONKeyBeforeCandidateCommit(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "secret")
|
||||
if err := os.WriteFile(secretPath, []byte("schemaVersion"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvironment(t, secretPath)
|
||||
candidate := []byte("candidates: []\n")
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(candidate))
|
||||
result := fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"suggest-fks","runId":"0123456789abcdef0123456789abcdef","completedStages":[],"artifactIdentities":[{"kind":"fk-candidates","digest":"%s"}],"hostExport":{"mediaType":"application/yaml","sha256":"%s","contentBase64":"%s"}}`, strings.Repeat("0", 40), strings.Repeat("a", 40), digest, digest, base64.StdEncoding.EncodeToString(candidate))
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", result)
|
||||
output := filepath.Join(fixture.root, "candidate.yaml")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "schema", "suggest-fks", "--workspace", "psd", "--output", output, "--json"}, &stdout, &stderr)
|
||||
if code != 1 || stdout.Len() != 0 {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
||||
t.Fatalf("candidate exists after final-byte secret rejection: %v", err)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "schemaVersion") {
|
||||
t.Fatalf("stderr leaked declared secret: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceRejectsDeclaredSecretInHumanChrome(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "secret")
|
||||
if err := os.WriteFile(secretPath, []byte("Workspace"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvironment(t, secretPath)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"0000000000000000000000000000000000000000","descriptorBlob":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation":"inspect","completedStages":[]}`)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
||||
if code != 1 || stdout.Len() != 0 || strings.Contains(stderr.String(), "Workspace") {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceErrorPrefixNeverLeaksDeclaredSecret(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "secret")
|
||||
if err := os.WriteFile(secretPath, []byte("thothctl:"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvironment(t, secretPath)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "not-json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
||||
if code != 1 || stdout.Len() != 0 || strings.Contains(stderr.String(), "thothctl:") {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceBoundsFinalHumanEncoding(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
|
||||
Reference in New Issue
Block a user