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
+1 -1
View File
@@ -52,7 +52,7 @@ struct AppGridView: View {
ZStack {
VStack(spacing: 8) {
HStack(spacing: 8) {
EmptyPill()
ShelfPill()
EmptyPill()
}
HStack(spacing: 8) {
+10
View File
@@ -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
+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
}
}
@@ -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.
+141
View File
@@ -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) }
}
}
}
+5 -2
View File
@@ -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")
+19 -3
View File
@@ -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)
@@ -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)