From 7a2090bb15b70cbd352992cedcbc2452e690d553 Mon Sep 17 00:00:00 2001 From: Subhrajit Makur Date: Thu, 22 Jan 2026 05:19:39 +0530 Subject: [PATCH] feat(config): add ShellQuote helper for safe env var escaping (#830) Add ShellQuote function to properly escape environment variable values containing shell special characters ({, }, *, $, ", etc.). Changes: - Add ShellQuote() that wraps values in single quotes when needed - Escape embedded single quotes using '\'' idiom - Update ExportPrefix to use ShellQuote - Update BuildStartupCommand and PrependEnv in loader.go - Add comprehensive tests for shell quoting edge cases Backwards compatible: paths, hyphens, dots, and slashes are NOT quoted, preserving existing agent behavior (GT_ROOT, BD_ACTOR, etc.). This is a prerequisite for the OpenCode agent preset which uses OPENCODE_PERMISSION='{"*":"allow"}' for auto-approve mode. Co-authored-by: Claude Opus 4.5 --- internal/config/env.go | 29 +++++++++- internal/config/env_test.go | 111 ++++++++++++++++++++++++++++++++++++ internal/config/loader.go | 7 ++- 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/internal/config/env.go b/internal/config/env.go index e5ea6f87..5b4ddd65 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -119,9 +119,36 @@ func AgentEnvSimple(role, rig, agentName string) map[string]string { }) } +// ShellQuote returns a shell-safe quoted string. +// Values containing special characters are wrapped in single quotes. +// Single quotes within the value are escaped using the '\'' idiom. +func ShellQuote(s string) string { + // Check if quoting is needed (contains shell special chars) + needsQuoting := false + for _, c := range s { + switch c { + case ' ', '\t', '\n', '"', '\'', '`', '$', '\\', '!', '*', '?', + '[', ']', '{', '}', '(', ')', '<', '>', '|', '&', ';', '#': + needsQuoting = true + } + if needsQuoting { + break + } + } + + if !needsQuoting { + return s + } + + // Use single quotes, escaping any embedded single quotes + // 'foo'\''bar' means: 'foo' + escaped-single-quote + 'bar' + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + // ExportPrefix builds an export statement prefix for shell commands. // Returns a string like "export GT_ROLE=mayor BD_ACTOR=mayor && " // The keys are sorted for deterministic output. +// Values containing special characters are properly shell-quoted. func ExportPrefix(env map[string]string) string { if len(env) == 0 { return "" @@ -136,7 +163,7 @@ func ExportPrefix(env map[string]string) string { var parts []string for _, k := range keys { - parts = append(parts, fmt.Sprintf("%s=%s", k, env[k])) + parts = append(parts, fmt.Sprintf("%s=%s", k, ShellQuote(env[k]))) } return "export " + strings.Join(parts, " ") + " && " diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 12b4a928..0dda66f9 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -175,6 +175,101 @@ func TestAgentEnv_EmptyTownRootOmitted(t *testing.T) { assertEnv(t, env, "GT_RIG", "myrig") } +func TestShellQuote(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple value no quoting", + input: "foobar", + expected: "foobar", + }, + { + name: "alphanumeric and underscore", + input: "FOO_BAR_123", + expected: "FOO_BAR_123", + }, + // CRITICAL: These values are used by existing agents and must NOT be quoted + { + name: "path with slashes (GT_ROOT, CLAUDE_CONFIG_DIR)", + input: "/home/user/.config/claude", + expected: "/home/user/.config/claude", // NOT quoted + }, + { + name: "BD_ACTOR with slashes", + input: "myrig/polecats/Toast", + expected: "myrig/polecats/Toast", // NOT quoted + }, + { + name: "value with hyphen", + input: "deacon-boot", + expected: "deacon-boot", // NOT quoted + }, + { + name: "value with dots", + input: "user.name", + expected: "user.name", // NOT quoted + }, + { + name: "value with spaces", + input: "hello world", + expected: "'hello world'", + }, + { + name: "value with double quotes", + input: `say "hello"`, + expected: `'say "hello"'`, + }, + { + name: "JSON object", + input: `{"*":"allow"}`, + expected: `'{"*":"allow"}'`, + }, + { + name: "OPENCODE_PERMISSION value", + input: `{"*":"allow"}`, + expected: `'{"*":"allow"}'`, + }, + { + name: "value with single quote", + input: "it's a test", + expected: `'it'\''s a test'`, + }, + { + name: "value with dollar sign", + input: "$HOME", + expected: "'$HOME'", + }, + { + name: "value with backticks", + input: "`whoami`", + expected: "'`whoami`'", + }, + { + name: "value with asterisk", + input: "*.txt", + expected: "'*.txt'", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShellQuote(tt.input) + if result != tt.expected { + t.Errorf("ShellQuote(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + func TestExportPrefix(t *testing.T) { t.Parallel() tests := []struct { @@ -201,6 +296,22 @@ func TestExportPrefix(t *testing.T) { }, expected: "export AAA=first MMM=middle ZZZ=last && ", }, + { + name: "JSON value is quoted", + env: map[string]string{ + "OPENCODE_PERMISSION": `{"*":"allow"}`, + }, + expected: `export OPENCODE_PERMISSION='{"*":"allow"}' && `, + }, + { + name: "mixed simple and complex values", + env: map[string]string{ + "SIMPLE": "value", + "COMPLEX": `{"key":"val"}`, + "GT_ROLE": "polecat", + }, + expected: `export COMPLEX='{"key":"val"}' GT_ROLE=polecat SIMPLE=value && `, + }, } for _, tt := range tests { diff --git a/internal/config/loader.go b/internal/config/loader.go index 612f6cae..87357df5 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1256,7 +1256,7 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri // Build environment export prefix var exports []string for k, v := range resolvedEnv { - exports = append(exports, fmt.Sprintf("%s=%s", k, v)) + exports = append(exports, fmt.Sprintf("%s=%s", k, ShellQuote(v))) } // Sort for deterministic output @@ -1282,6 +1282,7 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri } // PrependEnv prepends export statements to a command string. +// Values containing special characters are properly shell-quoted. func PrependEnv(command string, envVars map[string]string) string { if len(envVars) == 0 { return command @@ -1289,7 +1290,7 @@ func PrependEnv(command string, envVars map[string]string) string { var exports []string for k, v := range envVars { - exports = append(exports, fmt.Sprintf("%s=%s", k, v)) + exports = append(exports, fmt.Sprintf("%s=%s", k, ShellQuote(v))) } sort.Strings(exports) @@ -1365,7 +1366,7 @@ func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, pr // Build environment export prefix var exports []string for k, v := range resolvedEnv { - exports = append(exports, fmt.Sprintf("%s=%s", k, v)) + exports = append(exports, fmt.Sprintf("%s=%s", k, ShellQuote(v))) } sort.Strings(exports)