Skip to content

Commit fad6d47

Browse files
committed
v1.8.1 - AI-powered monitoring and improved documentation
- Add AI monitoring mode with Claude integration - AI analyzes each persistence change and decides notifications - Customizable AI behavior (prompt options, thresholds) - Menu bar shows active mode (AI/Std) - Settings button in toolbar and menu bar - Notification deduplication with configurable cooldown - Startup notification shows active mode - Comprehensive README documentation for AI and MCP - Remove DMG from repo (now in releases only)
1 parent 2b325c6 commit fad6d47

15 files changed

Lines changed: 2428 additions & 60 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ xcuserdata/
55
DerivedData/
66
.swiftpm/
77
MacPersistenceChecker.app/
8+
MacPersistenceChecker.dmg
9+
.claude/

MacPersistenceChecker.dmg

-6.07 MB
Binary file not shown.

MacPersistenceChecker/App/MacPersistenceCheckerApp.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,17 @@ struct SettingsView: View {
136136
Label("Monitoring", systemImage: "eye")
137137
}
138138

139+
AISettingsView()
140+
.tabItem {
141+
Label("AI", systemImage: "brain")
142+
}
143+
139144
AboutView()
140145
.tabItem {
141146
Label("About", systemImage: "info.circle")
142147
}
143148
}
144-
.frame(width: 550, height: 600)
149+
.frame(width: 600, height: 650)
145150
}
146151
}
147152

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import Foundation
2+
import Security
3+
4+
/// Prompt analysis options - structured settings for AI behavior
5+
struct AIPromptOptions: Codable, Equatable {
6+
/// Ignore items signed by Apple
7+
var ignoreAppleSigned: Bool = true
8+
/// Ignore system paths (/System, /Library)
9+
var ignoreSystemPaths: Bool = true
10+
/// Prioritize unsigned items
11+
var prioritizeUnsigned: Bool = true
12+
/// Focus on LOLBins detection
13+
var focusLOLBins: Bool = true
14+
/// Minimum risk score to analyze (0-100)
15+
var minimumRiskScore: Int = 0
16+
/// Custom paths to ignore (comma-separated)
17+
var ignoredPaths: String = ""
18+
19+
/// Generate prompt additions based on options
20+
var promptAdditions: String {
21+
var additions: [String] = []
22+
23+
if ignoreAppleSigned {
24+
additions.append("- Ignore or deprioritize items that are signed by Apple (com.apple.*)")
25+
}
26+
if ignoreSystemPaths {
27+
additions.append("- Deprioritize items in /System and /Library paths as they are typically system components")
28+
}
29+
if prioritizeUnsigned {
30+
additions.append("- Pay special attention to unsigned executables - these are higher risk")
31+
}
32+
if focusLOLBins {
33+
additions.append("- Focus on detecting Living-off-the-Land Binaries (LOLBins) usage patterns")
34+
}
35+
if minimumRiskScore > 0 {
36+
additions.append("- Only analyze items with risk score >= \(minimumRiskScore)")
37+
}
38+
if !ignoredPaths.isEmpty {
39+
additions.append("- Ignore items in these paths: \(ignoredPaths)")
40+
}
41+
42+
return additions.isEmpty ? "" : "\n\nAnalysis preferences:\n" + additions.joined(separator: "\n")
43+
}
44+
}
45+
46+
/// Severity levels for AI analysis
47+
enum AISeverity: String, CaseIterable, Comparable, Codable {
48+
case info = "info"
49+
case low = "low"
50+
case medium = "medium"
51+
case high = "high"
52+
case critical = "critical"
53+
54+
var displayName: String {
55+
rawValue.capitalized
56+
}
57+
58+
static func < (lhs: AISeverity, rhs: AISeverity) -> Bool {
59+
let order: [AISeverity] = [.info, .low, .medium, .high, .critical]
60+
guard let lhsIndex = order.firstIndex(of: lhs),
61+
let rhsIndex = order.firstIndex(of: rhs) else {
62+
return false
63+
}
64+
return lhsIndex < rhsIndex
65+
}
66+
}
67+
68+
/// AI Configuration manager
69+
@MainActor
70+
final class AIConfiguration: ObservableObject {
71+
static let shared = AIConfiguration()
72+
73+
// MARK: - AI Enable (requires API key)
74+
75+
/// Whether to use AI for analysis (only works if API key is valid)
76+
@Published var useAI: Bool {
77+
didSet { saveSettings() }
78+
}
79+
80+
// MARK: - Claude API Settings
81+
82+
@Published var claudeAPIKey: String {
83+
didSet {
84+
if !claudeAPIKey.isEmpty {
85+
saveAPIKeyToKeychain()
86+
} else {
87+
// If key is cleared, disable AI
88+
useAI = false
89+
}
90+
}
91+
}
92+
93+
@Published var claudeModel: String {
94+
didSet { saveSettings() }
95+
}
96+
97+
// MARK: - AI Analysis Settings
98+
99+
/// AI check interval in seconds (used by monitoring when AI is enabled)
100+
@Published var aiCheckInterval: TimeInterval {
101+
didSet { saveSettings() }
102+
}
103+
104+
/// Minimum severity to trigger notifications
105+
@Published var notificationThreshold: AISeverity {
106+
didSet { saveSettings() }
107+
}
108+
109+
// MARK: - Prompt Settings
110+
111+
/// Structured prompt options
112+
@Published var promptOptions: AIPromptOptions {
113+
didSet { saveSettings() }
114+
}
115+
116+
/// Custom prompt text (appended to default)
117+
@Published var customPrompt: String {
118+
didSet { saveSettings() }
119+
}
120+
121+
// MARK: - MCP Settings
122+
123+
/// Whether MCP server is enabled
124+
@Published var mcpServerEnabled: Bool {
125+
didSet { saveSettings() }
126+
}
127+
128+
/// Path to mpc-server binary
129+
@Published var mcpServerPath: String {
130+
didSet { saveSettings() }
131+
}
132+
133+
// MARK: - Initialization
134+
135+
private let keychainService = "com.macpersistencechecker.claude-api"
136+
private let settingsKey = "ai_configuration_v2"
137+
138+
private init() {
139+
// Load defaults
140+
self.useAI = false
141+
self.claudeAPIKey = ""
142+
self.claudeModel = "claude-sonnet-4-20250514"
143+
self.aiCheckInterval = 300 // 5 minutes
144+
self.notificationThreshold = .medium
145+
self.promptOptions = AIPromptOptions()
146+
self.customPrompt = ""
147+
self.mcpServerEnabled = false
148+
self.mcpServerPath = ""
149+
150+
// Load saved settings
151+
loadSettings()
152+
loadAPIKeyFromKeychain()
153+
154+
// Try to find MCP server binary
155+
if mcpServerPath.isEmpty {
156+
findMCPServerBinary()
157+
}
158+
159+
// Ensure useAI is false if no valid key
160+
if !isAPIKeyValid {
161+
useAI = false
162+
}
163+
}
164+
165+
// MARK: - Settings Persistence
166+
167+
private struct SavedSettings: Codable {
168+
let useAI: Bool
169+
let claudeModel: String
170+
let aiCheckInterval: TimeInterval
171+
let notificationThreshold: String
172+
let promptOptions: AIPromptOptions
173+
let customPrompt: String
174+
let mcpServerEnabled: Bool
175+
let mcpServerPath: String
176+
}
177+
178+
private func saveSettings() {
179+
let settings = SavedSettings(
180+
useAI: useAI,
181+
claudeModel: claudeModel,
182+
aiCheckInterval: aiCheckInterval,
183+
notificationThreshold: notificationThreshold.rawValue,
184+
promptOptions: promptOptions,
185+
customPrompt: customPrompt,
186+
mcpServerEnabled: mcpServerEnabled,
187+
mcpServerPath: mcpServerPath
188+
)
189+
190+
if let data = try? JSONEncoder().encode(settings) {
191+
UserDefaults.standard.set(data, forKey: settingsKey)
192+
}
193+
}
194+
195+
private func loadSettings() {
196+
guard let data = UserDefaults.standard.data(forKey: settingsKey),
197+
let settings = try? JSONDecoder().decode(SavedSettings.self, from: data) else {
198+
return
199+
}
200+
201+
self.useAI = settings.useAI
202+
self.claudeModel = settings.claudeModel
203+
self.aiCheckInterval = settings.aiCheckInterval
204+
if let threshold = AISeverity(rawValue: settings.notificationThreshold) {
205+
self.notificationThreshold = threshold
206+
}
207+
self.promptOptions = settings.promptOptions
208+
self.customPrompt = settings.customPrompt
209+
self.mcpServerEnabled = settings.mcpServerEnabled
210+
self.mcpServerPath = settings.mcpServerPath
211+
}
212+
213+
// MARK: - Keychain
214+
215+
private func saveAPIKeyToKeychain() {
216+
// Delete existing
217+
let deleteQuery: [String: Any] = [
218+
kSecClass as String: kSecClassGenericPassword,
219+
kSecAttrService as String: keychainService
220+
]
221+
SecItemDelete(deleteQuery as CFDictionary)
222+
223+
guard !claudeAPIKey.isEmpty else { return }
224+
225+
// Add new
226+
let addQuery: [String: Any] = [
227+
kSecClass as String: kSecClassGenericPassword,
228+
kSecAttrService as String: keychainService,
229+
kSecValueData as String: claudeAPIKey.data(using: .utf8)!
230+
]
231+
232+
let status = SecItemAdd(addQuery as CFDictionary, nil)
233+
if status != errSecSuccess {
234+
print("[AIConfiguration] Failed to save API key to Keychain: \(status)")
235+
}
236+
}
237+
238+
private func loadAPIKeyFromKeychain() {
239+
let query: [String: Any] = [
240+
kSecClass as String: kSecClassGenericPassword,
241+
kSecAttrService as String: keychainService,
242+
kSecReturnData as String: true
243+
]
244+
245+
var result: AnyObject?
246+
let status = SecItemCopyMatching(query as CFDictionary, &result)
247+
248+
if status == errSecSuccess, let data = result as? Data {
249+
self.claudeAPIKey = String(data: data, encoding: .utf8) ?? ""
250+
}
251+
}
252+
253+
// MARK: - MCP Server
254+
255+
private func findMCPServerBinary() {
256+
// Check common locations
257+
let possiblePaths = [
258+
// Build directory
259+
FileManager.default.currentDirectoryPath + "/.build/debug/mpc-server",
260+
FileManager.default.currentDirectoryPath + "/.build/release/mpc-server",
261+
// App bundle
262+
Bundle.main.bundlePath + "/Contents/MacOS/mpc-server",
263+
// User local
264+
FileManager.default.homeDirectoryForCurrentUser.path + "/.local/bin/mpc-server",
265+
"/usr/local/bin/mpc-server"
266+
]
267+
268+
for path in possiblePaths {
269+
if FileManager.default.fileExists(atPath: path) {
270+
mcpServerPath = path
271+
break
272+
}
273+
}
274+
}
275+
276+
/// Validate API key format
277+
var isAPIKeyValid: Bool {
278+
claudeAPIKey.hasPrefix("sk-ant-") && claudeAPIKey.count > 20
279+
}
280+
281+
/// Check if MCP server is available
282+
var isMCPServerAvailable: Bool {
283+
!mcpServerPath.isEmpty && FileManager.default.fileExists(atPath: mcpServerPath)
284+
}
285+
286+
/// Generate Claude Code MCP config snippet
287+
var mcpConfigSnippet: String {
288+
"""
289+
{
290+
"mcpServers": {
291+
"mac-persistence": {
292+
"command": "\(mcpServerPath)",
293+
"args": []
294+
}
295+
}
296+
}
297+
"""
298+
}
299+
300+
/// Available Claude models
301+
static let availableModels = [
302+
("claude-sonnet-4-20250514", "Claude Sonnet 4 (Recommended)"),
303+
("claude-opus-4-20250514", "Claude Opus 4 (Most Capable)"),
304+
("claude-3-5-haiku-20241022", "Claude 3.5 Haiku (Fastest)")
305+
]
306+
307+
/// AI check interval options
308+
static let intervalOptions: [(TimeInterval, String)] = [
309+
(30, "30 seconds"),
310+
(60, "1 minute"),
311+
(120, "2 minutes"),
312+
(300, "5 minutes"),
313+
(600, "10 minutes"),
314+
(900, "15 minutes"),
315+
(1800, "30 minutes"),
316+
(3600, "1 hour")
317+
]
318+
319+
/// Whether AI analysis is available (key is valid)
320+
var isAIAvailable: Bool {
321+
isAPIKeyValid
322+
}
323+
324+
/// Whether AI is actively enabled and available
325+
var isAIActive: Bool {
326+
useAI && isAPIKeyValid
327+
}
328+
329+
/// Full prompt including default + options + custom
330+
var fullAnalysisPrompt: String {
331+
var prompt = promptOptions.promptAdditions
332+
if !customPrompt.isEmpty {
333+
prompt += "\n\nAdditional instructions:\n\(customPrompt)"
334+
}
335+
return prompt
336+
}
337+
}

0 commit comments

Comments
 (0)