Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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, "&")
|
||||
}
|
||||
Reference in New Issue
Block a user