Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH

This commit is contained in:
Maksim Totmin
2026-10-01 10:13:48 +07:00
commit 1821fe7968
30 changed files with 5342 additions and 0 deletions
+309
View File
@@ -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, "&")
}