Files
forge-tools-ssh/internal/tools/shellsplit.go
T

310 lines
9.1 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 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 {
// Паттерн: <bin> -c <quote>...<quote> или <bin> -e <quote>...<quote>
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, "&")
}