Security + upstream fixes

- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore)
- Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days
- Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-07 23:49:11 +03:00
parent dc47247340
commit 52e1e8288b
21 changed files with 1070 additions and 188 deletions
+43 -57
View File
@@ -1156,40 +1156,34 @@ final class HookServer: @unchecked Sendable {
// MARK: - Claude Code settings.json hook installer
private var _pendingHooksData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingHooksOriginal: Data?
/// Returns preview JSON without writing — call writeClaudeHooks() to confirm.
func previewClaudeHooks() throws -> String {
let data = try buildHooksData()
let (data, original) = try buildHooksData()
_pendingHooksData = data
_pendingHooksOriginal = original
return String(data: data, encoding: .utf8) ?? ""
}
/// Writes the hooks to disk (call after user confirms preview).
/// Refused if settings.json changed since the preview, or cannot be backed up.
func writeClaudeHooks() throws {
guard let data = _pendingHooksData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
// Backup first
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingHooksOriginal)
_pendingHooksData = nil
_pendingHooksOriginal = nil
}
private func buildHooksData() throws -> Data {
private func buildHooksData() throws -> (data: Data, original: Data?) {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:]
if let data = try? Data(contentsOf: settingsURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
let hookPath = Self.hookScriptPath
#if APPSTORE
// Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag
@@ -1206,9 +1200,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10),
]
var hooks = settings["hooks"] as? [String: Any] ?? [:]
// "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? []
var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false }
existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]])
hooks[event] = existing
@@ -1221,15 +1216,16 @@ final class HookServer: @unchecked Sendable {
])
hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
}
func uninstallClaudeHooks() throws {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
guard let data = try? Data(contentsOf: settingsURL),
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
var hooks = settings["hooks"] as? [String: Any] else { return }
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] {
@@ -1245,7 +1241,7 @@ final class HookServer: @unchecked Sendable {
}
settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
}
// MARK: - Claude plan status line installer
@@ -1266,6 +1262,8 @@ final class HookServer: @unchecked Sendable {
}
private var _pendingStatusLineData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingStatusLineOriginal: Data?
private var _pendingPreviousData: Data?
private var _pendingDeletePrevious: Bool = false
@@ -1273,11 +1271,9 @@ final class HookServer: @unchecked Sendable {
func previewStatusLine(install: Bool) throws -> String {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:]
if let d = try? Data(contentsOf: settingsURL),
let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let settings = snapshot.object
let hookPath = Self.hookScriptPath
let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"")
let quotedCmd = "\"\(quotedPath)\" --statusline"
@@ -1346,6 +1342,7 @@ final class HookServer: @unchecked Sendable {
let data = try JSONSerialization.data(withJSONObject: newSettings,
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
_pendingStatusLineData = data
_pendingStatusLineOriginal = snapshot.bytes
// Build a compact diff: show only the statusLine key before → after
func slJSON(_ val: [String: Any]?) throws -> String {
@@ -1363,15 +1360,7 @@ final class HookServer: @unchecked Sendable {
guard let data = _pendingStatusLineData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingStatusLineOriginal)
// Commit side effects only after successful write
if let prevData = _pendingPreviousData {
try? prevData.write(to: statusLinePreviousURL, options: .atomic)
@@ -1380,6 +1369,7 @@ final class HookServer: @unchecked Sendable {
try? FileManager.default.removeItem(at: statusLinePreviousURL)
}
_pendingStatusLineData = nil
_pendingStatusLineOriginal = nil
_pendingPreviousData = nil
_pendingDeletePrevious = false
}
@@ -1390,7 +1380,7 @@ final class HookServer: @unchecked Sendable {
/// Writes nb-hook script and updates settings.json in one shot.
/// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed).
func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws {
let data = try buildHooksData(claudeURL: claudeURL)
let (data, original) = try buildHooksData(claudeURL: claudeURL)
// Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/
let coucouDir = claudeURL.appendingPathComponent("coucou")
@@ -1404,19 +1394,15 @@ final class HookServer: @unchecked Sendable {
// Write settings.json (with backup)
let settingsURL = claudeURL.appendingPathComponent("settings.json")
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let backupURL = claudeURL.appendingPathComponent("settings.json.bak-\(formatter.string(from: Date()))")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: original)
UserDefaults.standard.set(true, forKey: "coucouHooksInstalled")
}
func uninstallClaudeHooksAppStore(claudeURL: URL) throws {
let settingsURL = claudeURL.appendingPathComponent("settings.json")
guard let data = try? Data(contentsOf: settingsURL),
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
var hooks = settings["hooks"] as? [String: Any] else { return }
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] {
matchers.removeAll { matcher in
@@ -1431,17 +1417,15 @@ final class HookServer: @unchecked Sendable {
}
settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
UserDefaults.standard.set(false, forKey: "coucouHooksInstalled")
}
private func buildHooksData(claudeURL: URL) throws -> Data {
private func buildHooksData(claudeURL: URL) throws -> (data: Data, original: Data?) {
let settingsURL = claudeURL.appendingPathComponent("settings.json")
var settings: [String: Any] = [:]
if let data = try? Data(contentsOf: settingsURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
// Derive hook path from the panel-selected claudeURL (real ~/.claude, not container)
let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path
let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\""
@@ -1454,9 +1438,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10),
]
var hooks = settings["hooks"] as? [String: Any] ?? [:]
// "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? []
var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains {
($0["command"] as? String)?.contains("coucou") == true ||
($0["command"] as? String)?.contains("NotchBuddy") == true
@@ -1472,7 +1457,8 @@ final class HookServer: @unchecked Sendable {
])
hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
}
#endif