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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
// ExportPrefix builds an export statement prefix for shell commands.
|
||||||
// Returns a string like "export GT_ROLE=mayor BD_ACTOR=mayor && "
|
// Returns a string like "export GT_ROLE=mayor BD_ACTOR=mayor && "
|
||||||
// The keys are sorted for deterministic output.
|
// The keys are sorted for deterministic output.
|
||||||
|
// Values containing special characters are properly shell-quoted.
|
||||||
func ExportPrefix(env map[string]string) string {
|
func ExportPrefix(env map[string]string) string {
|
||||||
if len(env) == 0 {
|
if len(env) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -136,7 +163,7 @@ func ExportPrefix(env map[string]string) string {
|
|||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, k := range keys {
|
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, " ") + " && "
|
return "export " + strings.Join(parts, " ") + " && "
|
||||||
|
|||||||
@@ -175,6 +175,101 @@ func TestAgentEnv_EmptyTownRootOmitted(t *testing.T) {
|
|||||||
assertEnv(t, env, "GT_RIG", "myrig")
|
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) {
|
func TestExportPrefix(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -201,6 +296,22 @@ func TestExportPrefix(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "export AAA=first MMM=middle ZZZ=last && ",
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -1256,7 +1256,7 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri
|
|||||||
// Build environment export prefix
|
// Build environment export prefix
|
||||||
var exports []string
|
var exports []string
|
||||||
for k, v := range resolvedEnv {
|
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
|
// 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.
|
// 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 {
|
func PrependEnv(command string, envVars map[string]string) string {
|
||||||
if len(envVars) == 0 {
|
if len(envVars) == 0 {
|
||||||
return command
|
return command
|
||||||
@@ -1289,7 +1290,7 @@ func PrependEnv(command string, envVars map[string]string) string {
|
|||||||
|
|
||||||
var exports []string
|
var exports []string
|
||||||
for k, v := range envVars {
|
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)
|
sort.Strings(exports)
|
||||||
@@ -1365,7 +1366,7 @@ func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, pr
|
|||||||
// Build environment export prefix
|
// Build environment export prefix
|
||||||
var exports []string
|
var exports []string
|
||||||
for k, v := range resolvedEnv {
|
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)
|
sort.Strings(exports)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user