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.
207 lines
5.3 KiB
Go
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
|
|
}
|