Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions internal/runtime/gemma.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,20 +622,23 @@ func safePath(relPath, workDir string) (string, error) {
// It extracts the binary name from the command (first whitespace-delimited token)
// and validates that the full command starts with an allowlisted prefix followed
// by either a space, end-of-string, or the exact match. Shell metacharacters
// (;, |, &, $, `, \n) are rejected outright to prevent command chaining.
// (;, |, &, $, `, \n, \r, \t, NUL, <, >) are rejected outright to prevent
// command chaining, redirection, expansion, and substitution.
func isCommandAllowed(command string, allowlist []string) bool {
command = strings.TrimSpace(command)
if command == "" {
return false
}

// H9: reject any shell metacharacter that could chain commands, redirect
// I/O, or escape the allowlist. Belt-and-suspenders alongside the prefix
// match below.
for _, ch := range []string{";", "&&", "||", "|", "$(", "$", "`", ">", "<", "\n", "\r", "\t&"} {
if strings.Contains(command, ch) {
return false
}
// I/O, or escape the allowlist. ContainsAny over a canonical set is
// faster than per-pattern substring scans AND closes the gaps the prior
// list left open — bare tab (was only "\t&"), NUL byte (would otherwise
// pass the metachar check and then fail the prefix match, but better
// rejected loudly), and `\` (escapes).
const forbidden = ";&|$`<>\n\r\t\x00\\"
if strings.ContainsAny(command, forbidden) {
return false
}

for _, pattern := range allowlist {
Expand Down
27 changes: 27 additions & 0 deletions internal/runtime/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,33 @@ func TestIsCommandAllowed_EmptyCommand(t *testing.T) {
}
}

// TestIsCommandAllowed_RejectsExtendedMetachars covers the post-SEC-M3
// expansion of the forbidden set. Bare tab, NUL byte, redirection, and
// backslash were missing from the old ad-hoc substring loop. The new
// ContainsAny check rejects every dangerous control / metacharacter.
func TestIsCommandAllowed_RejectsExtendedMetachars(t *testing.T) {
cases := []struct {
name string
cmd string
}{
{"bare tab", "echo\thello"},
{"NUL byte", "echo\x00rm -rf /"},
{"redirect out", "echo hi > /etc/passwd"},
{"redirect in", "echo < /etc/passwd"},
{"newline", "echo ok\nrm -rf /"},
{"carriage return", "echo ok\rrm -rf /"},
{"backslash", "echo \\$HOME"},
{"bare dollar (env expansion)", "echo $HOME"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if isCommandAllowed(tc.cmd, []string{"echo"}) {
t.Errorf("must reject %q", tc.cmd)
}
})
}
}

func TestIsCommandAllowed_MultiWordPattern(t *testing.T) {
allowlist := []string{"go test", "go build"}
if !isCommandAllowed("go test ./...", allowlist) {
Expand Down
Loading