Files
gnoma/internal/tool/bash/security.go
vikingowl f0633d8ac6 feat: complete M1 — core engine with Mistral provider
Mistral provider adapter with streaming, tool calls (single-chunk
pattern), stop reason inference, model listing, capabilities, and
JSON output support.

Tool system: bash (7 security checks, shell alias harvesting for
bash/zsh/fish), file ops (read, write, edit, glob, grep, ls).
Alias harvesting collects 300+ aliases from user's shell config.

Engine agentic loop: stream → tool execution → re-query → until
done. Tool gating on model capabilities. Max turns safety limit.

CLI pipe mode: echo "prompt" | gnoma streams response to stdout.
Flags: --provider, --model, --system, --api-key, --max-turns,
--verbose, --version.

Provider interface expanded: Models(), DefaultModel(), Capabilities
(ToolUse, JSONOutput, Vision, Thinking, ContextWindow, MaxOutput),
ResponseFormat with JSON schema support.

Live verified: text streaming + tool calling with devstral-small.
117 tests across 8 packages, 10MB binary.
2026-04-03 12:01:55 +02:00

207 lines
5.3 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)
)
// 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
}
// Metacharacters and redirection are warnings, not blocks in M1.
// The LLM legitimately uses pipes and redirects.
// Full compound command parsing (mvdan.cc/sh) comes in M5.
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
}
// 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
}