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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user