From 37c01f991a1d71ce7af4c2813e0fb86edfa05895 Mon Sep 17 00:00:00 2001 From: maksarsanjeev Date: Tue, 6 Oct 2026 22:51:35 +0300 Subject: [PATCH] =?UTF-8?q?Danger=20warning=20on=20approval=20cards=20(red?= =?UTF-8?q?=20card,=20reasons,=20no=20Always);=20file=20shelf=20in=20the?= =?UTF-8?q?=20top-left=20slot=20with=20'=D0=9D=D0=B0=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BA=D1=83'=20after=20a=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- NotchBuddy/Sources/App/AppGridView.swift | 2 +- NotchBuddy/Sources/App/AppState.swift | 10 ++ NotchBuddy/Sources/App/DangerCheck.swift | 94 ++++++++++++ NotchBuddy/Sources/App/FileDropView.swift | 1 + NotchBuddy/Sources/App/FileShelf.swift | 141 ++++++++++++++++++ NotchBuddy/Sources/App/HookServer.swift | 7 +- .../Sources/App/IslandViewContent.swift | 22 ++- NotchBuddy/Sources/App/UploadCanvasView.swift | 23 +++ .../Sources/CoucouKit/IslandTypes.swift | 2 + 9 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 NotchBuddy/Sources/App/DangerCheck.swift create mode 100644 NotchBuddy/Sources/App/FileShelf.swift diff --git a/NotchBuddy/Sources/App/AppGridView.swift b/NotchBuddy/Sources/App/AppGridView.swift index d264173..8abb8bf 100644 --- a/NotchBuddy/Sources/App/AppGridView.swift +++ b/NotchBuddy/Sources/App/AppGridView.swift @@ -52,7 +52,7 @@ struct AppGridView: View { ZStack { VStack(spacing: 8) { HStack(spacing: 8) { - EmptyPill() + ShelfPill() EmptyPill() } HStack(spacing: 8) { diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 6468041..d51045a 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -243,6 +243,16 @@ final class AppState: ObservableObject { // Dropped file (set during upload flow) @Published var droppedFile: DroppedFile? = nil + /// Original URLs of the last drop (all of them), for "На полку". + var lastDroppedURLs: [URL] = [] + + /// Puts the last dropped files on the shelf and goes back home. + func shelveLastDrop() { + FileShelf.shared.add(lastDroppedURLs) + SoundEngine.shared.play("pop") + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud) + view = .overview + } // Short note message (shown in NoteView) @Published var noteMessage: String? = nil diff --git a/NotchBuddy/Sources/App/DangerCheck.swift b/NotchBuddy/Sources/App/DangerCheck.swift new file mode 100644 index 0000000..fab51b5 --- /dev/null +++ b/NotchBuddy/Sources/App/DangerCheck.swift @@ -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 = ["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 + } +} diff --git a/NotchBuddy/Sources/App/FileDropView.swift b/NotchBuddy/Sources/App/FileDropView.swift index 9a47a33..a836c97 100644 --- a/NotchBuddy/Sources/App/FileDropView.swift +++ b/NotchBuddy/Sources/App/FileDropView.swift @@ -47,6 +47,7 @@ enum FileDropHandler { static func handle(urls: [URL], state: AppState) async { guard let url = urls.first else { return } let name = url.lastPathComponent + state.lastDroppedURLs = urls // Start animation immediately — do NOT block on file copy. // Use original URL first; swap to inbox copy once background copy finishes. diff --git a/NotchBuddy/Sources/App/FileShelf.swift b/NotchBuddy/Sources/App/FileShelf.swift new file mode 100644 index 0000000..0cc6ae6 --- /dev/null +++ b/NotchBuddy/Sources/App/FileShelf.swift @@ -0,0 +1,141 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +// MARK: - File shelf +// +// Drop files on the notch → "На полку": they wait in the top-left slot of the home +// view. Drag an icon out to put the file anywhere, click to open it, right-click to +// reveal or remove. The shelf keeps references (paths), not copies, and survives restarts; +// files that no longer exist are dropped silently. + +struct ShelfItem: Identifiable, Codable, Equatable { + var id = UUID() + var path: String + var addedAt = Date() + + var url: URL { URL(fileURLWithPath: path) } + var name: String { url.lastPathComponent } +} + +@MainActor +final class FileShelf: ObservableObject { + static let shared = FileShelf() + static let maxItems = 12 + private let key = "fileShelf" + + @Published private(set) var items: [ShelfItem] = [] + + private init() { + if let data = UserDefaults.standard.data(forKey: key), + let saved = try? JSONDecoder().decode([ShelfItem].self, from: data) { + items = saved.filter { FileManager.default.fileExists(atPath: $0.path) } + } + } + + func add(_ urls: [URL]) { + for url in urls where !items.contains(where: { $0.path == url.path }) { + items.insert(ShelfItem(path: url.path), at: 0) + } + if items.count > Self.maxItems { items = Array(items.prefix(Self.maxItems)) } + save() + } + + func remove(_ item: ShelfItem) { + items.removeAll { $0.id == item.id } + save() + } + + func clear() { + items = [] + save() + } + + /// Drops entries whose file was moved or deleted. + func prune() { + let alive = items.filter { FileManager.default.fileExists(atPath: $0.path) } + if alive != items { items = alive; save() } + } + + private func save() { + if let data = try? JSONEncoder().encode(items) { + UserDefaults.standard.set(data, forKey: key) + } + } +} + +// MARK: - Shelf pill (home view, top-left slot) + +struct ShelfPill: View { + @ObservedObject private var shelf = FileShelf.shared + + var body: some View { + ZStack { + if shelf.items.isEmpty { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + Text("полка · брось файл на чёлку") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#4A4E55")) + } else { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(hex: "#0E0F11")) + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + HStack(spacing: 6) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(shelf.items) { item in + ShelfIcon(item: item) + } + } + .padding(.horizontal, 2) + } + Button { + shelf.clear() + SoundEngine.shared.play("close") + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(Color(hex: "#5F646D")) + .frame(width: 16, height: 16) + .background(Circle().fill(Color.white.opacity(0.06))) + } + .buttonStyle(.plain) + .help("Очистить полку") + } + .padding(.horizontal, 8) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { shelf.prune() } + } +} + +struct ShelfIcon: View { + let item: ShelfItem + @State private var isHovered = false + + var body: some View { + Image(nsImage: NSWorkspace.shared.icon(forFile: item.path)) + .resizable() + .interpolation(.high) + .frame(width: 28, height: 28) + .padding(2) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(isHovered ? 0.10 : 0))) + .scaleEffect(isHovered ? 1.08 : 1) + .onHover { h in withAnimation(.easeOut(duration: 0.12)) { isHovered = h } } + .help(item.name) + .onTapGesture { NSWorkspace.shared.open(item.url) } + .onDrag { + SoundEngine.shared.play("pop") + return NSItemProvider(contentsOf: item.url) ?? NSItemProvider() + } + .contextMenu { + Button("Открыть") { NSWorkspace.shared.open(item.url) } + Button("Показать в Finder") { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } + Divider() + Button("Убрать с полки") { FileShelf.shared.remove(item) } + } + } +} diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index af9c9dd..e3b1980 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -672,7 +672,8 @@ final class HookServer: @unchecked Sendable { return } - let command = toolInput["command"] as? String ?? tool + let command = DangerCheck.describe(tool: tool, input: toolInput) + let danger = DangerCheck.reasons(tool: tool, input: toolInput) if pendingApprovalFD >= 0 { // Displace the previous request: write "ask" then cancel its source. @@ -692,7 +693,9 @@ final class HookServer: @unchecked Sendable { upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) state.updateTask(id: pillId, state: .approval) state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool, - command: command, inputKey: inputKey, pillId: pillId) + command: command, inputKey: inputKey, pillId: pillId, + danger: danger) + if !danger.isEmpty { NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised) } state.isPinned = true SoundEngine.shared.play("approval") diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 378e100..5d9bde1 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -298,11 +298,25 @@ struct ApprovalView: View { var approval: ApprovalInfo? { state.pendingApproval } var body: some View { + let danger = approval?.danger ?? [] ZStack { - CardBackground(wash: .amber) + CardBackground(wash: danger.isEmpty ? .amber : .red) VStack(alignment: .leading, spacing: 5) { AgentWho(task: state.focusTask, label: "просит разрешение") CodeBlock(text: approval?.command ?? approval?.tool ?? "…") + if !danger.isEmpty { + HStack(alignment: .top, spacing: 5) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10, weight: .bold)) + Text("Опасно: " + danger.joined(separator: "; ")) + .font(.system(size: 10.5, weight: .semibold)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(Color(hex: "#FF6B76")) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(RoundedRectangle(cornerRadius: 6).fill(Color(hex: "#F4505E").opacity(0.14))) + } HStack(spacing: 8) { SecondaryButton("Запретить") { HookServer.shared.sendApprovalDecision("deny") @@ -310,8 +324,9 @@ struct ApprovalView: View { PrimaryButton("Разрешить") { HookServer.shared.sendApprovalDecision("allow") } - // Codex rejects updatedPermissions, so "Always" is not offered - if approval?.pillId != "agent_codex" { + // Codex rejects updatedPermissions, so "Always" is not offered. + // Never for a dangerous command: one click must not save a destructive rule. + if approval?.pillId != "agent_codex" && danger.isEmpty { SecondaryButton("Всегда") { HookServer.shared.sendApprovalDecision("always") } @@ -922,6 +937,7 @@ struct ChooseView: View { HStack(spacing: 8) { PrimaryButton("Задать вопрос") { state.view = .prompt } SecondaryButton("Отправить почтой") { state.view = .mail } + SecondaryButton("На полку") { state.shelveLastDrop() } } } .padding(.leading, 98) diff --git a/NotchBuddy/Sources/App/UploadCanvasView.swift b/NotchBuddy/Sources/App/UploadCanvasView.swift index 40d9764..3429ee3 100644 --- a/NotchBuddy/Sources/App/UploadCanvasView.swift +++ b/NotchBuddy/Sources/App/UploadCanvasView.swift @@ -80,6 +80,21 @@ struct UploadCanvasView: View { .buttonStyle(.plain) .frame(width: 120, height: 26) .position(x: 290 + 60, y: 113 + 13) // center = (350, 126) + + // Tertiary: put on the shelf + Button { + withAnimation(.easeInOut(duration: 0.22)) { state.shelveLastDrop() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + UploadSequenceEngine.shared.deactivate() + } + } label: { + Color.clear + .frame(width: 96, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(width: 96, height: 26) + .position(x: 418 + 48, y: 113 + 13) // center = (466, 126) } .opacity(f.chooseAlpha) .allowsHitTesting(f.chooseAlpha > 0.5) @@ -288,6 +303,14 @@ struct UploadCanvasView: View { .font(.system(size:12.5, weight:.medium)) .foregroundColor(Color(hex:"#F1F2F4")) cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center) + + // Tertiary button: shelf + cCtx.fill(roundedRect(CGRect(x:418,y:113,width:96,height:26), r:13), + with: .color(Color.white.opacity(0.09))) + let btn3 = Text("На полку") + .font(.system(size:12.5, weight:.medium)) + .foregroundColor(Color(hex:"#F1F2F4")) + cCtx.draw(btn3, at: CGPoint(x:466, y:126), anchor: .center) } // MARK: - Mochi (superellipse body + eyes + mouth) diff --git a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift index 477b97b..50dc400 100644 --- a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift +++ b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift @@ -38,6 +38,8 @@ struct ApprovalInfo: Sendable { var inputKey: String /// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex". var pillId: String + /// Why this request looks destructive (DangerCheck), empty when nothing matched. + var danger: [String] = [] } // MARK: - Pill badge (shown on pill edge when non-focused task has an alert)