provider/openai: - Fix doubled tool call args (argsComplete flag): Ollama sends complete args in the first streaming chunk then repeats them as delta, causing doubled JSON and 400 errors in elfs - Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep) - Add Reasoning field support for Ollama thinking output cmd/gnoma: - Early TTY detection so logger is created with correct destination before any component gets a reference to it (fixes slog WARN bleed into TUI textarea) permission: - Exempt spawn_elfs and agent tools from safety scanner: elf prompt text may legitimately mention .env/.ssh/credentials patterns and should not be blocked tui/app: - /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge (ask for plain text output) → TUI fallback write from streamBuf - looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback content before writing (reject refusals, strip narrative preambles) - Collapse thinking output to 3 lines; ctrl+o to expand (live stream and committed messages) - Stream-level filter for model pseudo-tool-call blocks: suppresses <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|> from entering streamBuf across chunk boundaries - sanitizeAssistantText regex covers both block formats - Reset streamFilterClose at every turn start
539 lines
15 KiB
Go
539 lines
15 KiB
Go
package bash
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// SecurityCheck identifies a specific validation check.
|
||
type SecurityCheck int
|
||
|
||
const (
|
||
CheckIncomplete SecurityCheck = iota + 1 // fragments, trailing operators
|
||
CheckMetacharacters // ; | & $ ` < >
|
||
CheckCmdSubstitution // $(), ``, ${}
|
||
CheckRedirection // < > >> etc.
|
||
CheckDangerousVars // IFS, PATH manipulation
|
||
CheckNewlineInjection // embedded newlines
|
||
CheckControlChars // ASCII 00-1F (except \n \t)
|
||
CheckJQInjection // jq with shell metacharacters
|
||
CheckObfuscatedFlags // Unicode lookalike hyphens
|
||
CheckProcEnviron // /proc/*/environ access
|
||
CheckBraceExpansion // dangerous {a,b} expansion
|
||
CheckUnicodeWhitespace // non-ASCII whitespace
|
||
CheckZshDangerous // zsh-specific dangerous constructs
|
||
CheckCommentDesync // # inside strings hiding commands
|
||
CheckIndirectExec // eval, bash -c, curl|bash, source
|
||
)
|
||
|
||
// SecurityViolation describes a failed security check.
|
||
type SecurityViolation struct {
|
||
Check SecurityCheck
|
||
Message string
|
||
}
|
||
|
||
func (v SecurityViolation) Error() string {
|
||
return fmt.Sprintf("bash security check %d: %s", v.Check, v.Message)
|
||
}
|
||
|
||
// ValidateCommand runs the 7 critical security checks against a command string.
|
||
// Returns nil if all checks pass, or the first violation found.
|
||
func ValidateCommand(cmd string) *SecurityViolation {
|
||
if strings.TrimSpace(cmd) == "" {
|
||
return &SecurityViolation{Check: CheckIncomplete, Message: "empty command"}
|
||
}
|
||
|
||
// Check incomplete on raw command (before trimming) to catch tab-starts
|
||
if v := checkIncomplete(cmd); v != nil {
|
||
return v
|
||
}
|
||
|
||
cmd = strings.TrimSpace(cmd)
|
||
|
||
if v := checkControlChars(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkNewlineInjection(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkCmdSubstitution(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkDangerousVars(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkStandaloneSemicolon(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkSensitiveRedirection(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkJQInjection(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkObfuscatedFlags(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkProcEnviron(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkBraceExpansion(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkUnicodeWhitespace(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkZshDangerous(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkCommentQuoteDesync(cmd); v != nil {
|
||
return v
|
||
}
|
||
if v := checkIndirectExec(cmd); v != nil {
|
||
return v
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkIncomplete detects command fragments that shouldn't be executed.
|
||
func checkIncomplete(cmd string) *SecurityViolation {
|
||
// Starts with tab (likely a fragment from indented code)
|
||
if cmd[0] == '\t' {
|
||
return &SecurityViolation{Check: CheckIncomplete, Message: "command starts with tab (likely a code fragment)"}
|
||
}
|
||
// Starts with a flag (no command name)
|
||
if cmd[0] == '-' {
|
||
return &SecurityViolation{Check: CheckIncomplete, Message: "command starts with flag (no command name)"}
|
||
}
|
||
// Ends with a dangling operator
|
||
trimmed := strings.TrimRight(cmd, " \t")
|
||
if len(trimmed) > 0 {
|
||
last := trimmed[len(trimmed)-1]
|
||
if last == '|' || last == '&' || last == ';' {
|
||
return &SecurityViolation{Check: CheckIncomplete, Message: "command ends with dangling operator"}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkControlChars blocks ASCII control characters (0x00-0x1F) except \n and \t.
|
||
func checkControlChars(cmd string) *SecurityViolation {
|
||
for i, r := range cmd {
|
||
if r < 0x20 && r != '\n' && r != '\t' && r != '\r' {
|
||
return &SecurityViolation{
|
||
Check: CheckControlChars,
|
||
Message: fmt.Sprintf("control character U+%04X at position %d", r, i),
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkNewlineInjection blocks commands with embedded newlines.
|
||
// Newlines in quoted strings are legitimate but rare in single commands.
|
||
// We allow them inside single/double quotes only.
|
||
func checkNewlineInjection(cmd string) *SecurityViolation {
|
||
inSingle := false
|
||
inDouble := false
|
||
escaped := false
|
||
|
||
for _, r := range cmd {
|
||
if escaped {
|
||
escaped = false
|
||
continue
|
||
}
|
||
if r == '\\' && !inSingle {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if r == '\'' && !inDouble {
|
||
inSingle = !inSingle
|
||
continue
|
||
}
|
||
if r == '"' && !inSingle {
|
||
inDouble = !inDouble
|
||
continue
|
||
}
|
||
if r == '\n' && !inSingle && !inDouble {
|
||
return &SecurityViolation{
|
||
Check: CheckNewlineInjection,
|
||
Message: "unquoted newline (potential command injection)",
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkCmdSubstitution blocks $(), ``, and ${} command/variable substitution.
|
||
// These allow arbitrary code execution within a command.
|
||
func checkCmdSubstitution(cmd string) *SecurityViolation {
|
||
inSingle := false
|
||
escaped := false
|
||
|
||
for i, r := range cmd {
|
||
if escaped {
|
||
escaped = false
|
||
continue
|
||
}
|
||
if r == '\\' && !inSingle {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if r == '\'' {
|
||
inSingle = !inSingle
|
||
continue
|
||
}
|
||
|
||
// Skip checks inside single quotes (literal)
|
||
if inSingle {
|
||
continue
|
||
}
|
||
|
||
if r == '`' {
|
||
return &SecurityViolation{
|
||
Check: CheckCmdSubstitution,
|
||
Message: "backtick command substitution",
|
||
}
|
||
}
|
||
|
||
if r == '$' && i+1 < len(cmd) {
|
||
next := rune(cmd[i+1])
|
||
if next == '(' {
|
||
return &SecurityViolation{
|
||
Check: CheckCmdSubstitution,
|
||
Message: "$() command substitution",
|
||
}
|
||
}
|
||
if next == '{' {
|
||
return &SecurityViolation{
|
||
Check: CheckCmdSubstitution,
|
||
Message: "${} variable expansion",
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkStandaloneSemicolon blocks standalone semicolons used to chain commands.
|
||
// Pipes (|) and && / || are allowed (handled by compound command parsing).
|
||
func checkStandaloneSemicolon(cmd string) *SecurityViolation {
|
||
inSingle := false
|
||
inDouble := false
|
||
escaped := false
|
||
|
||
for _, r := range cmd {
|
||
if escaped {
|
||
escaped = false
|
||
continue
|
||
}
|
||
if r == '\\' && !inSingle {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if r == '\'' && !inDouble {
|
||
inSingle = !inSingle
|
||
continue
|
||
}
|
||
if r == '"' && !inSingle {
|
||
inDouble = !inDouble
|
||
continue
|
||
}
|
||
if !inSingle && !inDouble && r == ';' {
|
||
return &SecurityViolation{
|
||
Check: CheckMetacharacters,
|
||
Message: "standalone semicolon (use && for chaining)",
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkSensitiveRedirection blocks output redirection to sensitive paths.
|
||
// Detects: >, >>, fd redirects (2>), and no-space variants (>/etc/passwd).
|
||
func checkSensitiveRedirection(cmd string) *SecurityViolation {
|
||
sensitiveTargets := []string{
|
||
"/etc/passwd", "/etc/shadow", "/etc/sudoers",
|
||
".bashrc", ".zshrc", ".profile", ".bash_profile",
|
||
".ssh/authorized_keys", ".ssh/config",
|
||
".env",
|
||
}
|
||
|
||
for _, target := range sensitiveTargets {
|
||
// Match any form: >, >>, 2>, 2>>, &> followed by optional whitespace then target
|
||
idx := strings.Index(cmd, target)
|
||
if idx <= 0 {
|
||
continue
|
||
}
|
||
// Check what precedes the target (skip whitespace backwards)
|
||
pre := strings.TrimRight(cmd[:idx], " \t")
|
||
if len(pre) > 0 && (pre[len(pre)-1] == '>' || strings.HasSuffix(pre, ">>")) {
|
||
return &SecurityViolation{
|
||
Check: CheckRedirection,
|
||
Message: fmt.Sprintf("redirection to sensitive path: %s", target),
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkJQInjection detects jq commands with embedded shell metacharacters in the filter.
|
||
func checkJQInjection(cmd string) *SecurityViolation {
|
||
// Only check commands that invoke jq
|
||
if !strings.Contains(cmd, "jq ") && !strings.HasPrefix(cmd, "jq") {
|
||
return nil
|
||
}
|
||
// jq filters with $( or ` indicate shell injection through jq
|
||
dangerousInJQ := []string{"$(", "`", "system(", "input|"}
|
||
for _, d := range dangerousInJQ {
|
||
if strings.Contains(cmd, d) {
|
||
return &SecurityViolation{
|
||
Check: CheckJQInjection,
|
||
Message: fmt.Sprintf("jq command with dangerous pattern: %s", d),
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkObfuscatedFlags detects Unicode lookalike characters used as hyphens.
|
||
// Attackers use en-dash (–), em-dash (—), minus sign (−) instead of ASCII hyphen.
|
||
func checkObfuscatedFlags(cmd string) *SecurityViolation {
|
||
lookalikes := []rune{
|
||
'\u2013', // en-dash –
|
||
'\u2014', // em-dash —
|
||
'\u2212', // minus sign −
|
||
'\uFE63', // small hyphen-minus ﹣
|
||
'\uFF0D', // fullwidth hyphen-minus -
|
||
}
|
||
for i, r := range cmd {
|
||
for _, look := range lookalikes {
|
||
if r == look {
|
||
return &SecurityViolation{
|
||
Check: CheckObfuscatedFlags,
|
||
Message: fmt.Sprintf("Unicode lookalike hyphen U+%04X at position %d", r, i),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkProcEnviron blocks access to /proc/*/environ and /proc/self/mem.
|
||
func checkProcEnviron(cmd string) *SecurityViolation {
|
||
dangerous := []string{
|
||
"/proc/self/environ",
|
||
"/proc/self/mem",
|
||
"/proc/self/cmdline",
|
||
}
|
||
lower := strings.ToLower(cmd)
|
||
for _, d := range dangerous {
|
||
if strings.Contains(lower, d) {
|
||
return &SecurityViolation{
|
||
Check: CheckProcEnviron,
|
||
Message: fmt.Sprintf("access to %s (environment exfiltration)", d),
|
||
}
|
||
}
|
||
}
|
||
// Also catch /proc/*/environ with PID
|
||
if strings.Contains(lower, "/proc/") && strings.Contains(lower, "/environ") {
|
||
return &SecurityViolation{
|
||
Check: CheckProcEnviron,
|
||
Message: "/proc/PID/environ access (environment exfiltration)",
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkBraceExpansion detects dangerous brace expansion patterns.
|
||
// {a,b} is used to expand multiple arguments — can bypass argument filters.
|
||
func checkBraceExpansion(cmd string) *SecurityViolation {
|
||
inSingle := false
|
||
inDouble := false
|
||
braceDepth := 0
|
||
|
||
for _, r := range cmd {
|
||
if r == '\'' && !inDouble {
|
||
inSingle = !inSingle
|
||
continue
|
||
}
|
||
if r == '"' && !inSingle {
|
||
inDouble = !inDouble
|
||
continue
|
||
}
|
||
if inSingle || inDouble {
|
||
continue
|
||
}
|
||
if r == '{' {
|
||
braceDepth++
|
||
}
|
||
if r == '}' && braceDepth > 0 {
|
||
braceDepth--
|
||
}
|
||
// Comma inside braces = brace expansion
|
||
if r == ',' && braceDepth > 0 {
|
||
return &SecurityViolation{
|
||
Check: CheckBraceExpansion,
|
||
Message: "brace expansion {a,b} (can bypass argument filters)",
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkUnicodeWhitespace detects non-ASCII whitespace characters that can hide commands.
|
||
func checkUnicodeWhitespace(cmd string) *SecurityViolation {
|
||
for i, r := range cmd {
|
||
if r > 127 && unicode.IsSpace(r) {
|
||
return &SecurityViolation{
|
||
Check: CheckUnicodeWhitespace,
|
||
Message: fmt.Sprintf("non-ASCII whitespace U+%04X at position %d", r, i),
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkZshDangerous detects zsh-specific dangerous constructs.
|
||
// Note: <() and >() are intentionally excluded — they are also valid bash process
|
||
// substitution patterns used in legitimate commands (e.g., diff <(cmd1) <(cmd2)).
|
||
func checkZshDangerous(cmd string) *SecurityViolation {
|
||
dangerousPatterns := []struct {
|
||
pattern string
|
||
msg string
|
||
}{
|
||
{"=(", "zsh =() process substitution (arbitrary execution)"},
|
||
{"zmodload", "zsh module loading (can load arbitrary code)"},
|
||
{"sysopen", "zsh sysopen (direct file descriptor access)"},
|
||
{"ztcp", "zsh TCP socket access"},
|
||
{"zsocket", "zsh socket access"},
|
||
}
|
||
for _, p := range dangerousPatterns {
|
||
if strings.Contains(cmd, p.pattern) {
|
||
return &SecurityViolation{
|
||
Check: CheckZshDangerous,
|
||
Message: p.msg,
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkCommentQuoteDesync detects # characters that could be interpreted differently
|
||
// depending on shell parsing context (e.g., mid-word # in zsh vs bash).
|
||
func checkCommentQuoteDesync(cmd string) *SecurityViolation {
|
||
inSingle := false
|
||
inDouble := false
|
||
escaped := false
|
||
prevWasSpace := true
|
||
|
||
for _, r := range cmd {
|
||
if escaped {
|
||
escaped = false
|
||
prevWasSpace = false
|
||
continue
|
||
}
|
||
if r == '\\' && !inSingle {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if r == '\'' && !inDouble {
|
||
inSingle = !inSingle
|
||
prevWasSpace = false
|
||
continue
|
||
}
|
||
if r == '"' && !inSingle {
|
||
inDouble = !inDouble
|
||
prevWasSpace = false
|
||
continue
|
||
}
|
||
if inSingle || inDouble {
|
||
prevWasSpace = false
|
||
continue
|
||
}
|
||
// # at start of word is a comment — legit after whitespace
|
||
// # mid-word is suspicious in zsh (history expansion, etc.)
|
||
if r == '#' && !prevWasSpace {
|
||
return &SecurityViolation{
|
||
Check: CheckCommentDesync,
|
||
Message: "mid-word # character (comment/history expansion ambiguity)",
|
||
}
|
||
}
|
||
prevWasSpace = unicode.IsSpace(r)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkDangerousVars blocks attempts to manipulate IFS or PATH.
|
||
func checkDangerousVars(cmd string) *SecurityViolation {
|
||
upper := strings.ToUpper(cmd)
|
||
dangerousPatterns := []struct {
|
||
pattern string
|
||
msg string
|
||
}{
|
||
{"IFS=", "IFS variable manipulation"},
|
||
{"PATH=", "PATH variable manipulation"},
|
||
}
|
||
|
||
for _, p := range dangerousPatterns {
|
||
idx := strings.Index(upper, p.pattern)
|
||
if idx == -1 {
|
||
continue
|
||
}
|
||
// Only flag if it's at the start or preceded by whitespace/semicolon
|
||
if idx == 0 || !unicode.IsLetter(rune(cmd[idx-1])) {
|
||
return &SecurityViolation{Check: CheckDangerousVars, Message: p.msg}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkIndirectExec blocks commands that run arbitrary code indirectly,
|
||
// bypassing all other security checks applied to the outer command string.
|
||
// These are the highest-risk patterns in an agentic context.
|
||
func checkIndirectExec(cmd string) *SecurityViolation {
|
||
lower := strings.ToLower(cmd)
|
||
|
||
// Patterns that execute arbitrary content not visible to the checker.
|
||
// Each entry is a substring to look for (after lowercasing).
|
||
patterns := []struct {
|
||
needle string
|
||
msg string
|
||
}{
|
||
{"eval ", "eval executes arbitrary code (bypasses all checks)"},
|
||
{"eval\t", "eval executes arbitrary code (bypasses all checks)"},
|
||
{"bash -c", "bash -c executes arbitrary inline code"},
|
||
{"sh -c", "sh -c executes arbitrary inline code"},
|
||
{"zsh -c", "zsh -c executes arbitrary inline code"},
|
||
{"| bash", "pipe to bash executes downloaded/piped content"},
|
||
{"| sh", "pipe to sh executes downloaded/piped content"},
|
||
{"| zsh", "pipe to zsh executes downloaded/piped content"},
|
||
{"|bash", "pipe to bash executes downloaded/piped content"},
|
||
{"|sh", "pipe to sh executes downloaded/piped content"},
|
||
{"source ", "source executes arbitrary script files"},
|
||
{"source\t", "source executes arbitrary script files"},
|
||
}
|
||
|
||
for _, p := range patterns {
|
||
if strings.Contains(lower, p.needle) {
|
||
return &SecurityViolation{
|
||
Check: CheckIndirectExec,
|
||
Message: p.msg,
|
||
}
|
||
}
|
||
}
|
||
|
||
// Dot-source: ". ./script.sh" or ". /path/script.sh"
|
||
// Careful: don't block ". " that is just "cd" followed by space
|
||
if strings.HasPrefix(lower, ". /") || strings.HasPrefix(lower, ". ./") ||
|
||
strings.Contains(lower, " . /") || strings.Contains(lower, " . ./") {
|
||
return &SecurityViolation{
|
||
Check: CheckIndirectExec,
|
||
Message: "dot-source executes arbitrary script files",
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|