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
@@ -0,0 +1,144 @@
import Foundation
// MARK: - ClaudeSettingsFile
// Reads and rewrites a settings file BroV does not own (~/.claude/settings.json).
// Never start from an empty object when the file is there but unusable, always
// take a backup, and only ever write over the exact bytes the user was shown.
enum ClaudeSettingsFile {
enum Failure: LocalizedError, Equatable {
case unreadable(String)
case invalid(String)
case changed(String)
case backupFailed(String)
case writeFailed(String)
case unexpectedHooks(String)
var errorDescription: String? {
switch self {
case .unreadable(let name):
return "Не удалось прочитать \(name) — BroV его не трогал."
case .invalid(let name):
return "\(name) — некорректный JSON, BroV его не трогал."
case .changed(let name):
return "\(name) изменился после предпросмотра. Ничего не записано — откройте предпросмотр заново."
case .backupFailed(let name):
return "Не удалось сделать резервную копию \(name). Ничего не записано."
case .writeFailed(let name):
return "Не удалось записать \(name). Оригинал не тронут."
case .unexpectedHooks(let name):
return "\(name): \"hooks\" имеет неожиданный тип — BroV его не трогал."
}
}
}
/// The "hooks" object of a settings file. Absent → empty.
/// Present but not an object → throws, so it is never replaced.
static func hooks(in settings: [String: Any], name: String) throws -> [String: Any] {
guard let value = settings["hooks"] else { return [:] }
guard let hooks = value as? [String: Any] else { throw Failure.unexpectedHooks(name) }
return hooks
}
/// The hook groups already declared for one event. Absent → empty.
/// Present but not a list of objects → throws, so it is never replaced.
static func hookGroups(in hooks: [String: Any], event: String, name: String) throws -> [[String: Any]] {
guard let value = hooks[event] else { return [] }
guard let groups = value as? [[String: Any]] else { throw Failure.unexpectedHooks(name) }
return groups
}
/// The settings object and the bytes it was parsed from.
/// Absent file → empty object and nil bytes. An empty file is an empty object.
/// Present but unreadable, or anything that is not a JSON object → throws:
/// not knowing what is in there is not the same as empty.
static func read(at url: URL) throws -> (object: [String: Any], bytes: Data?) {
guard FileManager.default.fileExists(atPath: url.path) else { return ([:], nil) }
let name = url.lastPathComponent
guard let bytes = try? Data(contentsOf: url) else { throw Failure.unreadable(name) }
if bytes.allSatisfy({ $0 == 0x20 || $0 == 0x09 || $0 == 0x0A || $0 == 0x0D }) {
return ([:], bytes)
}
guard let object = (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] else {
throw Failure.invalid(name)
}
return (object, bytes)
}
/// Replaces the file with `data`, after a dated backup.
///
/// `original` is what `read` returned when `data` was computed. If the file
/// holds anything else by now — another tool, the user's own editor — nothing
/// is written. Returns the backup, or nil when there was no file to back up.
@discardableResult
static func write(_ data: Data, to url: URL, expecting original: Data?) throws -> URL? {
let fm = FileManager.default
let name = url.lastPathComponent
let exists = fm.fileExists(atPath: url.path)
var current: Data? = nil
if exists {
guard let bytes = try? Data(contentsOf: url) else { throw Failure.unreadable(name) }
current = bytes
}
guard current == original else { throw Failure.changed(name) }
// A dotfiles setup often makes settings.json a symlink: write to the file
// it points at, so the link survives the rename below.
let target = url.resolvingSymlinksInPath()
var backupURL: URL? = nil
// settings.json can hold API keys in its `env` block: a new file is ours
// only, and a rewrite keeps the permissions the original had.
var mode = 0o600
if exists {
let backup = freeBackupURL(for: url)
do { try fm.copyItem(at: target, to: backup) } catch { throw Failure.backupFailed(name) }
backupURL = backup
if let found = (try? fm.attributesOfItem(atPath: target.path))?[.posixPermissions] as? NSNumber {
mode = found.intValue & 0o777
}
} else {
try? fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
}
// Written beside the target and renamed over it: a crash or a full disk
// leaves the original intact rather than half a file.
let temp = target.deletingLastPathComponent()
.appendingPathComponent("\(target.lastPathComponent).brov-\(ProcessInfo.processInfo.processIdentifier)")
try? fm.removeItem(at: temp)
guard fm.createFile(atPath: temp.path, contents: data,
attributes: [.posixPermissions: NSNumber(value: 0o600)]) else {
throw Failure.writeFailed(name)
}
do {
try fm.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: temp.path)
} catch {
try? fm.removeItem(at: temp)
throw Failure.writeFailed(name)
}
guard rename(temp.path, target.path) == 0 else {
try? fm.removeItem(at: temp)
throw Failure.writeFailed(name)
}
return backupURL
}
/// Down to the second, and never an existing name: installing then
/// uninstalling in the same second must not lose the first backup.
private static func freeBackupURL(for url: URL) -> URL {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd-HHmmss"
let base = "\(url.lastPathComponent).bak-\(formatter.string(from: Date()))"
let dir = url.deletingLastPathComponent()
var candidate = dir.appendingPathComponent(base)
var n = 2
while FileManager.default.fileExists(atPath: candidate.path) {
candidate = dir.appendingPathComponent("\(base)-\(n)")
n += 1
}
return candidate
}
}