Files
gnoma/internal/tool/bash/security.go
vikingowl cb2d63d06f feat: Ollama/gemma4 compat — /init flow, stream filter, safety fixes
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
2026-04-05 19:24:51 +02:00

539 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}