fix: harden task1 workspace output and publication
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
|
||||
@@ -68,10 +69,13 @@ func main() {
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
// Apply the public stream bounds after all sanitization and final encoding.
|
||||
// In particular, JSON escaping can expand an otherwise accepted child result.
|
||||
stdout = &boundedWriter{dst: stdout, maximum: maxPublicStdoutBytes}
|
||||
stderr = &boundedWriter{dst: stderr, maximum: maxPublicStderrBytes}
|
||||
// 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.
|
||||
if len(args) > 2 && args[2] == "workspace" {
|
||||
stdout = &boundedWriter{dst: stdout, maximum: maxPublicStdoutBytes}
|
||||
stderr = &boundedWriter{dst: stderr, maximum: maxPublicStderrBytes}
|
||||
}
|
||||
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
|
||||
fmt.Fprint(stdout, usage)
|
||||
return 0
|
||||
@@ -128,13 +132,19 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
case workspaceops.RunRequest:
|
||||
jsonMode = c.JSON
|
||||
}
|
||||
publicResult, projectionErr := projectWorkspaceResult(result, secretValues)
|
||||
if projectionErr != nil {
|
||||
fmt.Fprintln(stderr, "thothctl: invalid workspace result")
|
||||
return 1
|
||||
}
|
||||
if jsonMode {
|
||||
if err := writeWorkspaceJSON(stdout, result); err != nil {
|
||||
if err := writeWorkspaceJSON(stdout, publicResult); err != nil {
|
||||
fmt.Fprintln(stderr, "thothctl: workspace result exceeds output limit")
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
renderWorkspaceHuman(stdout, result)
|
||||
} else if err := renderWorkspaceHuman(stdout, publicResult); err != nil {
|
||||
fmt.Fprintln(stderr, "thothctl: workspace result exceeds output limit")
|
||||
return 1
|
||||
}
|
||||
if result.Status == "blocked" {
|
||||
return 3
|
||||
@@ -271,18 +281,76 @@ func writeWorkspaceJSON(w io.Writer, result workspaceops.Result) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func renderWorkspaceHuman(w io.Writer, result workspaceops.Result) {
|
||||
func projectWorkspaceResult(result workspaceops.Result, secretValues []string) (workspaceops.Result, error) {
|
||||
project := func(value string) (string, error) {
|
||||
value = output.Sanitize(value, secretValues)
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) {
|
||||
return "", errors.New("control character in workspace result")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
var err error
|
||||
for _, value := range []*string{&result.Status, &result.Code, &result.WorkspaceID, &result.WorkspaceRevision, &result.DescriptorBlob, &result.Operation, &result.RunID} {
|
||||
if *value, err = project(*value); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
}
|
||||
if result.ChildRuns != nil {
|
||||
childRuns := make(map[string]string, len(result.ChildRuns))
|
||||
for key, value := range result.ChildRuns {
|
||||
publicKey, keyErr := project(key)
|
||||
if keyErr != nil {
|
||||
return workspaceops.Result{}, keyErr
|
||||
}
|
||||
publicValue, valueErr := project(value)
|
||||
if valueErr != nil {
|
||||
return workspaceops.Result{}, valueErr
|
||||
}
|
||||
childRuns[publicKey] = publicValue
|
||||
}
|
||||
result.ChildRuns = childRuns
|
||||
}
|
||||
for i := range result.CompletedStages {
|
||||
if result.CompletedStages[i], err = project(result.CompletedStages[i]); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
}
|
||||
for i := range result.Warnings {
|
||||
if result.Warnings[i], err = project(result.Warnings[i]); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
}
|
||||
for i := range result.ArtifactIdentities {
|
||||
if result.ArtifactIdentities[i].Kind, err = project(result.ArtifactIdentities[i].Kind); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
if result.ArtifactIdentities[i].Digest, err = project(result.ArtifactIdentities[i].Digest); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func renderWorkspaceHuman(w io.Writer, result workspaceops.Result) error {
|
||||
if result.Code == workspaceops.CodeRegistryBootstrapRecoveryConflict {
|
||||
fmt.Fprintln(w, "Bootstrap recovery is ambiguous or corrupt; inspect the installation registry jobs.")
|
||||
return
|
||||
_, err := fmt.Fprintln(w, "Bootstrap recovery is ambiguous or corrupt; inspect the installation registry jobs.")
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "Workspace %s: %s (%s)\n", result.WorkspaceID, result.Status, result.Code); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(w, "Workspace %s: %s (%s)\n", result.WorkspaceID, result.Status, result.Code)
|
||||
if result.RunID != "" {
|
||||
fmt.Fprintf(w, "Run: %s\n", result.RunID)
|
||||
if _, err := fmt.Fprintf(w, "Run: %s\n", result.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(result.CompletedStages) > 0 {
|
||||
fmt.Fprintf(w, "Completed stages: %s\n", strings.Join(result.CompletedStages, ", "))
|
||||
_, err := fmt.Fprintf(w, "Completed stages: %s\n", strings.Join(result.CompletedStages, ", "))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) {
|
||||
|
||||
@@ -878,6 +878,50 @@ func TestRunPiStatusPreservesDockerExitCodeAndRedactsDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspacePublicProjectionRedactsSecrets(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "TOKEN_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "token")
|
||||
if err := os.WriteFile(secretPath, []byte("workspace-secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvironment(t, secretPath)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"inspect","completedStages":["stage workspace-secret"],"warnings":["warning workspace-secret"]}`, strings.Repeat("0", 40), strings.Repeat("a", 40)))
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("run() exit=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "workspace-secret") || !strings.Contains(stdout.String(), "[REDACTED]") {
|
||||
t.Fatalf("public JSON = %q, want redacted secret", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspacePublicProjectionRejectsHumanControlCharacters(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"inspect","completedStages":["safe\nforged"]}`, strings.Repeat("0", 40), strings.Repeat("a", 40)))
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr); code != 1 || stdout.Len() != 0 {
|
||||
t.Fatalf("run() exit=%d stdout=%q stderr=%q, want rejected output", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNonWorkspaceOutputIsNotGloballyCapped(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
logPath := filepath.Join(fixture.root, "large.log")
|
||||
if err := os.WriteFile(logPath, []byte(strings.Repeat("log-line\n", 200000)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("THOTHCTL_FAKE_LOG_FILE", logPath)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("run() exit=%d stderr=%q invocations=%#v", code, stderr.String(), fixture.invocations(t))
|
||||
}
|
||||
if stdout.Len() <= maxPublicStdoutBytes {
|
||||
t.Fatalf("non-workspace output length=%d, want greater than cap %d", stdout.Len(), maxPublicStdoutBytes)
|
||||
}
|
||||
}
|
||||
|
||||
type cliFixture struct {
|
||||
root string
|
||||
installationPath string
|
||||
@@ -951,7 +995,8 @@ case " $* " in
|
||||
*"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
|
||||
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
||||
*"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
|
||||
*" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;;
|
||||
*" logs "*)
|
||||
if [ -n "${THOTHCTL_FAKE_LOG_FILE:-}" ]; then /bin/cat "$THOTHCTL_FAKE_LOG_FILE"; else printf '%s\n' "$THOTHCTL_FAKE_LOG"; fi ;;
|
||||
esac
|
||||
if [ "${THOTHCTL_FAKE_FAIL_ON:-}" = "version" ]; then
|
||||
printf '%s\n' "${THOTHCTL_FAKE_FAILURE:-fake Docker failure}" >&2
|
||||
@@ -986,6 +1031,7 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
|
||||
t.Setenv("THOTHCTL_FAKE_ARGS", f.argsFile)
|
||||
t.Setenv("THOTHCTL_FAKE_EXIT", "0")
|
||||
t.Setenv("THOTHCTL_FAKE_LOG", "")
|
||||
t.Setenv("THOTHCTL_FAKE_LOG_FILE", "")
|
||||
t.Setenv("THOTHCTL_FAKE_FAILURE", "")
|
||||
t.Setenv("THOTHCTL_FAKE_FAIL_ON", "")
|
||||
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", "[]")
|
||||
|
||||
Reference in New Issue
Block a user