310 lines
9.1 KiB
Go
310 lines
9.1 KiB
Go
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, "&")
|
||
}
|