package tools import ( "strings" ) // shellSplitPatterns разбивает shell-команду на отдельные "команды" и // возвращает permission-паттерн на каждую + безопасный префикс "always". // // Зачем: ядро forge не умеет парсить shell, поэтому безопасность rule- // matching (напр. "ssh__run": {"rm -rf *": deny}) обеспечивает тул - он // режет цепочки `cmd1 && cmd2`, `cmd1; cmd2`, `cmd1 | cmd2`, редиректы и // подстановки `$(...)`/backtick/`sh -c` на отдельные команды, чтобы // деструктив не "прятался" за безопасным префиксом (opencode делает то же // самое в tool/shell.ts, расщепляя AST). // // Возврат nil, если команда не может быть разобрана (несбалансированные // кавычки/скобки) - тогда ядро трактует как "паттернов нет" и решает по // default (ask - безопасно). Это консервативный отказ на неоднозначность. func shellSplitPatterns(command string) (patterns, always []string) { segs, ok := splitTopLevel(command) if !ok { return nil, nil } for _, seg := range segs { seg = strings.TrimSpace(seg) if seg == "" { continue } patterns = append(patterns, seg) always = append(always, safeAlwaysPrefix(seg)) // Вложенные команды: $(...), backtick, sh -c '...', python -c, etc. for _, inner := range extractNested(seg) { inner = strings.TrimSpace(inner) if inner == "" { continue } patterns = append(patterns, inner) always = append(always, safeAlwaysPrefix(inner)) } } return patterns, always } // splitTopLevel разбивает строку на сегменты по command-разделителям // `;`, `&&`, `||`, `|`, `|&`, `&`, перевод строки - на верхнем уровне // (вне кавычек и $()-скобок). Возвращает ok=false при несбалансированных // кавычках/скобках. func splitTopLevel(s string) ([]string, bool) { var segs []string var b strings.Builder inSingle := false inDouble := false escaped := false depth := 0 for i := 0; i < len(s); i++ { c := s[i] if escaped { b.WriteByte(c) escaped = false continue } if c == '\\' { b.WriteByte(c) escaped = true continue } if inSingle { b.WriteByte(c) if c == '\'' { inSingle = false } continue } if inDouble { b.WriteByte(c) if c == '"' { inDouble = false } continue } switch c { case '\'': b.WriteByte(c) inSingle = true case '"': b.WriteByte(c) inDouble = true case '(': depth++ b.WriteByte(c) case ')': if depth == 0 { return nil, false // несбалансированная скобка } depth-- b.WriteByte(c) case ';', '\n': if depth == 0 { segs = append(segs, b.String()) b.Reset() } else { b.WriteByte(c) } case '&': if depth == 0 { // && - разделитель; одиночный & - тоже разделитель // (background), поглощаем возможный второй & if i+1 < len(s) && s[i+1] == '&' { i++ } segs = append(segs, b.String()) b.Reset() } else { b.WriteByte(c) } case '|': if depth == 0 { // |& или | - разделитель if i+1 < len(s) && s[i+1] == '&' { i++ } segs = append(segs, b.String()) b.Reset() } else { b.WriteByte(c) } default: b.WriteByte(c) } } if inSingle || inDouble || depth != 0 { return nil, false } segs = append(segs, b.String()) return segs, true } // extractNested извлекает вложенные команды из сегмента: содержимое // `$(...)`, backtick-подстановки и аргументы `-c`/`-e` для // sh/bash/python/node. Это позволяет deny-правилам ловить деструктив, // спрятанный внутри подстановки или внешнего интерпретатора. func extractNested(seg string) []string { var out []string out = append(out, extractDollarParen(seg)...) out = append(out, extractBackticks(seg)...) out = append(out, extractInterpreterArg(seg)...) return out } // extractDollarParen извлекает содержимое `$( ... )` с учётом вложенности // и кавычек. func extractDollarParen(s string) []string { var out []string for i := 0; i < len(s); i++ { if i+1 < len(s) && s[i] == '$' && s[i+1] == '(' { end, inner, ok := matchBalanced(s, i+1, '(', ')') if !ok { return out } out = append(out, inner) i = end - 1 } } return out } // extractBackticks извлекает содержимое “ ` ... ` “. func extractBackticks(s string) []string { var out []string for i := 0; i < len(s); i++ { if s[i] == '`' { end := strings.IndexByte(s[i+1:], '`') if end < 0 { return out } out = append(out, s[i+1:i+1+end]) i += end + 1 } } return out } // extractInterpreterArg извлекает строку-аргумент у интерпретаторов, // исполняющих код: sh -c '...', bash -c '...', python -c '...', // node -e '...'. Это отдельная команда (её содержимое), которую нельзя // отождествлять с безопасным префиксом `sh -c`. func extractInterpreterArg(s string) []string { // Паттерн: -c ... или -e ... idx := strings.Index(s, " -c ") if idx < 0 { idx = strings.Index(s, " -e ") } if idx < 0 { return nil } rest := s[idx+4:] rest = strings.TrimSpace(rest) if rest == "" { return nil } // Разбираем аргумент: кавычки '...' или "..." var inner string switch rest[0] { case '\'': end := strings.IndexByte(rest[1:], '\'') if end < 0 { return nil } inner = rest[1 : 1+end] case '"': end := strings.IndexByte(rest[1:], '"') if end < 0 { return nil } inner = rest[1 : 1+end] default: // Без кавычек - берём до конца слова (грубая эвристика). end := strings.IndexAny(rest, " \t;|&") if end < 0 { end = len(rest) } inner = rest[:end] } if strings.TrimSpace(inner) == "" { return nil } return []string{inner} } // matchBalanced находит закрывающую скобку для открывающей на позиции // open (open - индекс символа '('), уважая кавычки и вложенность. // Возвращает (endIndex, innerContent, ok), где endIndex - индекс ')'. func matchBalanced(s string, open int, openCh, closeCh byte) (int, string, bool) { depth := 0 inSingle := false inDouble := false escaped := false for i := open; i < len(s); i++ { c := s[i] if escaped { escaped = false continue } if c == '\\' { escaped = true continue } if inSingle { if c == '\'' { inSingle = false } continue } if inDouble { if c == '"' { inDouble = false } continue } switch c { case '\'': inSingle = true case '"': inDouble = true case openCh: depth++ case closeCh: depth-- if depth == 0 { return i, s[open+1 : i], true } } } return 0, "", false } // safeAlwaysPrefix строит безопасный префикс "always" для команды: // "<команда>[ <подкоманда>] *". Подкоманда учитывается только для ПРОСТЫХ // команд (без кавычек/подстановок/редиректов/разделителей) и если второй // токен не флаг и не путь. Так одобрение `git status` запоминается как // `git status *` (будущие status-вызовы без вопроса), а `cat /etc/hosts` // и `echo $(rm -rf /)` - как `cat *` / `echo *` (без подкоманды: это // аргументы/подстановки, а не подкоманды). Консервативно: при // сомнении оставляем только имя команды. func safeAlwaysPrefix(command string) string { tokens := strings.Fields(command) if len(tokens) == 0 { return "*" } prefix := []string{tokens[0]} if len(tokens) > 1 && isSimpleCommand(command) { t := tokens[1] if !strings.HasPrefix(t, "-") && !strings.Contains(t, "/") { prefix = append(prefix, t) } } return strings.Join(prefix, " ") + " *" } // isSimpleCommand сообщает, является ли сегмент простой командой (без // кавычек, подстановок, редиректов и разделителей). Для таких команд // безопасный префикс может включать подкоманду; для сложных - нет. func isSimpleCommand(s string) bool { return !strings.ContainsAny(s, "`'\"$(;|<>") && !strings.Contains(s, "&") }