Danger warning on approval cards (red card, reasons, no Always); file shelf in the top-left slot with 'На полку' after a drop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 22:51:35 +03:00
parent 3857776111
commit 37c01f991a
9 changed files with 296 additions and 6 deletions
+94
View File
@@ -0,0 +1,94 @@
import Foundation
// MARK: - Destructive command warning
//
// Looks at a permission request (tool + tool_input) and returns short Russian reasons
// when it can destroy data or history, e.g. ["удаляет файлы рекурсивно"]. The approval
// card shows them in red and hides "Всегда" so a dangerous rule is never saved by one click.
// Heuristics only: an empty result does not mean the command is safe.
enum DangerCheck {
struct Rule {
let pattern: String
let reason: String
}
/// Shell command rules (case-insensitive regex, matched against the whole command).
static let shellRules: [Rule] = [
Rule(pattern: #"\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|-r\b.*-f\b|-f\b.*-r\b|--recursive|-r\b|-R\b)"#,
reason: "удаляет файлы рекурсивно"),
Rule(pattern: #"\brm\s+.*(\s|^)(/|~|\$HOME|\*)(\s|$)"#, reason: "удаляет корень, домашнюю папку или всё подряд"),
Rule(pattern: #"\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b|\s\+\S)"#, reason: "перезаписывает историю на сервере (force push)"),
Rule(pattern: #"\bgit\s+reset\s+--hard\b"#, reason: "выбрасывает несохранённые изменения (reset --hard)"),
Rule(pattern: #"\bgit\s+clean\s+-[a-z]*f"#, reason: "удаляет неотслеживаемые файлы (git clean)"),
Rule(pattern: #"\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)"#, reason: "откатывает все изменения в рабочей папке"),
Rule(pattern: #"\bgit\s+branch\s+-D\b"#, reason: "удаляет ветку без проверки слияния"),
Rule(pattern: #"\bgit\s+(rebase|filter-branch|filter-repo)\b"#, reason: "переписывает историю git"),
Rule(pattern: #"\bdrop\s+(table|database|schema|index|view)\b"#, reason: "удаляет таблицу или базу (DROP)"),
Rule(pattern: #"\btruncate\s+(table\s+)?\w"#, reason: "очищает таблицу (TRUNCATE)"),
Rule(pattern: #"\bdelete\s+from\s+\w+(\s*;|\s*$|\s*"|\s*')"#, reason: "удаляет все строки (DELETE без WHERE)"),
Rule(pattern: #"\bupdate\s+\w+\s+set\b(?!.*\bwhere\b)"#, reason: "меняет все строки (UPDATE без WHERE)"),
Rule(pattern: #"\b(mkfs|diskutil\s+(erase|zero|reformat|partition)|fdisk|newfs)"#, reason: "форматирует диск"),
Rule(pattern: #"\bdd\s+.*\bof="#, reason: "пишет напрямую на устройство (dd)"),
Rule(pattern: #"\bchmod\s+(-R\s+)?(0?777|a\+rwx)"#, reason: "открывает файлы всем на запись"),
Rule(pattern: #"\b(chmod|chown)\s+-R\b.*(\s/|\s~)"#, reason: "меняет права рекурсивно в системной или домашней папке"),
Rule(pattern: #"\bsudo\b"#, reason: "выполняется с правами администратора (sudo)"),
Rule(pattern: #"(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python3?)\b"#, reason: "скачивает и сразу запускает скрипт из интернета"),
Rule(pattern: #":\(\)\s*\{\s*:\|:&\s*\};:"#, reason: "fork-бомба"),
Rule(pattern: #"\b(kill|pkill|killall)\s+(-9\s+)?(-1\b|1\b|Finder|Dock|WindowServer|loginwindow)"#, reason: "убивает системные процессы"),
Rule(pattern: #"\b(shutdown|reboot|halt)\b"#, reason: "выключает или перезагружает компьютер"),
Rule(pattern: #"\bdocker\s+(system\s+prune|volume\s+(rm|prune)|rm\s+-f)"#, reason: "удаляет контейнеры или тома Docker"),
Rule(pattern: #"\bkubectl\s+delete\b"#, reason: "удаляет ресурсы в Kubernetes"),
Rule(pattern: #"\bterraform\s+(destroy|apply\s+.*-auto-approve)"#, reason: "меняет или уничтожает инфраструктуру"),
Rule(pattern: #"\b(npm|pnpm|yarn)\s+publish\b|\bgh\s+release\s+create\b"#, reason: "публикует наружу"),
Rule(pattern: #"\bgh\s+repo\s+(delete|edit\s+.*--visibility\s+public)"#, reason: "удаляет репозиторий или делает его публичным"),
Rule(pattern: #">\s*(/etc/|/dev/sd|/dev/disk|~/\.ssh/|~/\.zshrc|~/\.bashrc)"#, reason: "перезаписывает системный или личный конфиг"),
Rule(pattern: #"\bfind\b.*\s-delete\b|\bfind\b.*-exec\s+rm\b"#, reason: "удаляет найденные файлы пачкой"),
Rule(pattern: #"\b(security\s+delete-|defaults\s+delete\b|launchctl\s+(unload|remove|bootout))"#, reason: "меняет системные настройки macOS"),
]
/// Paths that are dangerous to write or edit.
static let sensitivePaths: [(pattern: String, reason: String)] = [
(#"(^|/)\.env(\.|$)"#, "правит секреты (.env)"),
(#"/\.ssh/"#, "правит SSH-ключи или конфиг"),
(#"^/(etc|System|Library|usr|bin|sbin)/"#, "пишет в системную папку"),
(#"/\.claude/settings(\.local)?\.json$"#, "меняет настройки Claude Code"),
(#"/\.(zshrc|bashrc|zprofile|bash_profile|gitconfig)$"#, "правит конфиг оболочки или git"),
(#"/\.git/"#, "правит внутренности репозитория (.git)"),
(#"(id_rsa|id_ed25519|\.pem|\.key|credentials|secrets?)(\.|$)"#, "трогает ключи или секреты"),
]
static func reasons(tool: String, input: [String: Any]) -> [String] {
var out: [String] = []
if let command = input["command"] as? String {
for rule in shellRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
out.append(rule.reason)
}
}
let fileTools: Set<String> = ["Write", "Edit", "MultiEdit", "NotebookEdit"]
if fileTools.contains(tool) {
let path = (input["file_path"] as? String ?? input["notebook_path"] as? String ?? "")
let expanded = (path as NSString).expandingTildeInPath
for (pattern, reason) in sensitivePaths where matches(pattern, expanded) && !out.contains(reason) {
out.append(reason)
}
}
return out
}
static func matches(_ pattern: String, _ text: String) -> Bool {
text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
}
/// What the approval card shows for non-shell tools: "Write · ~/proj/.env".
static func describe(tool: String, input: [String: Any]) -> String {
if let command = input["command"] as? String { return command }
if let path = (input["file_path"] ?? input["notebook_path"] ?? input["path"]) as? String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let short = path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path
return "\(tool) · \(short)"
}
if let url = input["url"] as? String { return "\(tool) · \(url)" }
return tool
}
}