Skip to content

Commit e461a4c

Browse files
pablotpclaude
andcommitted
Harden socket security and add hook installation consent
- Change Unix socket permissions from 0o777 to 0o600 (owner-only) - Move socket from /tmp/claude-island.sock to ~/.claude/claude-island.sock - Add token-based authentication to the socket protocol - Require user consent before modifying ~/.claude/settings.json Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0c92dfc commit e461a4c

5 files changed

Lines changed: 125 additions & 6 deletions

File tree

ClaudeIsland/App/AppDelegate.swift

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6767
Mixpanel.mainInstance().track(event: "App Launched")
6868
Mixpanel.mainInstance().flush()
6969

70-
HookInstaller.installIfNeeded()
70+
installHooksWithConsent()
7171
NSApplication.shared.setActivationPolicy(.accessory)
7272

7373
windowManager = WindowManager()
@@ -91,6 +91,44 @@ class AppDelegate: NSObject, NSApplicationDelegate {
9191
_ = windowManager?.setupNotchWindow()
9292
}
9393

94+
private func installHooksWithConsent() {
95+
let consent = AppSettings.hooksConsentGiven
96+
97+
if consent == true {
98+
HookInstaller.installIfNeeded()
99+
return
100+
}
101+
102+
if consent == false {
103+
return
104+
}
105+
106+
// consent == nil: first launch, never asked
107+
// Existing users with hooks already installed get implied consent
108+
if HookInstaller.isInstalled() {
109+
AppSettings.hooksConsentGiven = true
110+
HookInstaller.installIfNeeded()
111+
return
112+
}
113+
114+
// New user: show consent dialog
115+
let alert = NSAlert()
116+
alert.messageText = "Enable Claude Code Integration?"
117+
alert.informativeText = "Claude Island can integrate with Claude Code by installing hooks into your ~/.claude/settings.json configuration. This enables real-time status monitoring and permission management from the menu bar.\n\nYou can change this later in Settings."
118+
alert.alertStyle = .informational
119+
alert.addButton(withTitle: "Enable Hooks")
120+
alert.addButton(withTitle: "Not Now")
121+
122+
let response = alert.runModal()
123+
124+
if response == .alertFirstButtonReturn {
125+
AppSettings.hooksConsentGiven = true
126+
HookInstaller.installIfNeeded()
127+
} else {
128+
AppSettings.hooksConsentGiven = false
129+
}
130+
}
131+
94132
func applicationWillTerminate(_ notification: Notification) {
95133
Mixpanel.mainInstance().flush()
96134
updateCheckTimer?.invalidate()

ClaudeIsland/Core/Settings.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ enum AppSettings {
3838

3939
private enum Keys {
4040
static let notificationSound = "notificationSound"
41+
static let hooksConsentGiven = "hooksConsentGiven"
4142
}
4243

4344
// MARK: - Notification Sound
@@ -55,4 +56,24 @@ enum AppSettings {
5556
defaults.set(newValue.rawValue, forKey: Keys.notificationSound)
5657
}
5758
}
59+
60+
// MARK: - Hooks Consent
61+
62+
/// Whether the user has given consent for hooks installation.
63+
/// Returns nil if never asked (first launch).
64+
static var hooksConsentGiven: Bool? {
65+
get {
66+
guard defaults.object(forKey: Keys.hooksConsentGiven) != nil else {
67+
return nil
68+
}
69+
return defaults.bool(forKey: Keys.hooksConsentGiven)
70+
}
71+
set {
72+
if let value = newValue {
73+
defaults.set(value, forKey: Keys.hooksConsentGiven)
74+
} else {
75+
defaults.removeObject(forKey: Keys.hooksConsentGiven)
76+
}
77+
}
78+
}
5879
}

ClaudeIsland/Resources/claude-island-state.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,20 @@
99
import socket
1010
import sys
1111

12-
SOCKET_PATH = "/tmp/claude-island.sock"
12+
SOCKET_PATH = os.path.expanduser("~/.claude/claude-island.sock")
13+
TOKEN_PATH = os.path.expanduser("~/.claude/hooks/.claude-island-token")
1314
TIMEOUT_SECONDS = 300 # 5 minutes for permission decisions
1415

1516

17+
def get_token():
18+
"""Read the authentication token written by ClaudeIsland.app"""
19+
try:
20+
with open(TOKEN_PATH, "r") as f:
21+
return f.read().strip()
22+
except (OSError, IOError):
23+
return None
24+
25+
1626
def get_tty():
1727
"""Get the TTY of the Claude process (parent)"""
1828
import subprocess
@@ -95,6 +105,11 @@ def main():
95105
"tty": tty,
96106
}
97107

108+
# Add authentication token
109+
token = get_token()
110+
if token:
111+
state["token"] = token
112+
98113
# Map events to status
99114
if event == "UserPromptSubmit":
100115
# User just sent a message - Claude is now processing

ClaudeIsland/Services/Hooks/HookSocketServer.swift

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,19 @@ struct HookEvent: Codable, Sendable {
2525
let toolUseId: String?
2626
let notificationType: String?
2727
let message: String?
28+
let token: String?
2829

2930
enum CodingKeys: String, CodingKey {
3031
case sessionId = "session_id"
3132
case cwd, event, status, pid, tty, tool
3233
case toolInput = "tool_input"
3334
case toolUseId = "tool_use_id"
3435
case notificationType = "notification_type"
35-
case message
36+
case message, token
3637
}
3738

3839
/// Create a copy with updated toolUseId
39-
init(sessionId: String, cwd: String, event: String, status: String, pid: Int?, tty: String?, tool: String?, toolInput: [String: AnyCodable]?, toolUseId: String?, notificationType: String?, message: String?) {
40+
init(sessionId: String, cwd: String, event: String, status: String, pid: Int?, tty: String?, tool: String?, toolInput: [String: AnyCodable]?, toolUseId: String?, notificationType: String?, message: String?, token: String? = nil) {
4041
self.sessionId = sessionId
4142
self.cwd = cwd
4243
self.event = event
@@ -48,6 +49,7 @@ struct HookEvent: Codable, Sendable {
4849
self.toolUseId = toolUseId
4950
self.notificationType = notificationType
5051
self.message = message
52+
self.token = token
5153
}
5254

5355
var sessionPhase: SessionPhase {
@@ -107,9 +109,16 @@ typealias PermissionFailureHandler = @Sendable (_ sessionId: String, _ toolUseId
107109
/// Uses GCD DispatchSource for non-blocking I/O
108110
class HookSocketServer {
109111
static let shared = HookSocketServer()
110-
static let socketPath = "/tmp/claude-island.sock"
112+
static let socketPath: String = FileManager.default.homeDirectoryForCurrentUser
113+
.appendingPathComponent(".claude/claude-island.sock")
114+
.path
115+
static let tokenPath: String = FileManager.default.homeDirectoryForCurrentUser
116+
.appendingPathComponent(".claude/hooks/.claude-island-token")
117+
.path
111118

112119
private var serverSocket: Int32 = -1
120+
/// The expected token for authenticating hook connections
121+
private var expectedToken: String?
113122
private var acceptSource: DispatchSourceRead?
114123
private var eventHandler: HookEventHandler?
115124
private var permissionFailureHandler: PermissionFailureHandler?
@@ -141,6 +150,8 @@ class HookSocketServer {
141150
permissionFailureHandler = onPermissionFailure
142151

143152
unlink(Self.socketPath)
153+
// Clean up legacy socket path from previous versions
154+
unlink("/tmp/claude-island.sock")
144155

145156
serverSocket = socket(AF_UNIX, SOCK_STREAM, 0)
146157
guard serverSocket >= 0 else {
@@ -174,7 +185,7 @@ class HookSocketServer {
174185
return
175186
}
176187

177-
chmod(Self.socketPath, 0o777)
188+
chmod(Self.socketPath, 0o600)
178189

179190
guard listen(serverSocket, 10) == 0 else {
180191
logger.error("Failed to listen: \(errno)")
@@ -185,6 +196,8 @@ class HookSocketServer {
185196

186197
logger.info("Listening on \(Self.socketPath, privacy: .public)")
187198

199+
expectedToken = generateAndWriteToken()
200+
188201
acceptSource = DispatchSource.makeReadSource(fileDescriptor: serverSocket, queue: queue)
189202
acceptSource?.setEventHandler { [weak self] in
190203
self?.acceptConnection()
@@ -203,6 +216,7 @@ class HookSocketServer {
203216
acceptSource?.cancel()
204217
acceptSource = nil
205218
unlink(Self.socketPath)
219+
unlink(Self.tokenPath)
206220

207221
permissionsLock.lock()
208222
for (_, pending) in pendingPermissions {
@@ -280,6 +294,26 @@ class HookSocketServer {
280294
permissionsLock.unlock()
281295
}
282296

297+
// MARK: - Token Authentication
298+
299+
/// Generate a random token and write it to the token file
300+
private func generateAndWriteToken() -> String {
301+
let token = UUID().uuidString
302+
let tokenDir = (Self.tokenPath as NSString).deletingLastPathComponent
303+
try? FileManager.default.createDirectory(
304+
atPath: tokenDir,
305+
withIntermediateDirectories: true,
306+
attributes: nil
307+
)
308+
FileManager.default.createFile(
309+
atPath: Self.tokenPath,
310+
contents: token.data(using: .utf8),
311+
attributes: [.posixPermissions: 0o600]
312+
)
313+
logger.info("Auth token written to \(Self.tokenPath, privacy: .public)")
314+
return token
315+
}
316+
283317
// MARK: - Tool Use ID Cache
284318

285319
/// Encoder with sorted keys for deterministic cache keys
@@ -411,6 +445,15 @@ class HookSocketServer {
411445
return
412446
}
413447

448+
// Validate authentication token
449+
if let expected = expectedToken {
450+
guard event.token == expected else {
451+
logger.warning("Rejected event with invalid token from \(event.sessionId.prefix(8), privacy: .public)")
452+
close(clientSocket)
453+
return
454+
}
455+
}
456+
414457
logger.debug("Received: \(event.event, privacy: .public) for \(event.sessionId.prefix(8), privacy: .public)")
415458

416459
if event.event == "PreToolUse" {

ClaudeIsland/UI/Views/NotchMenuView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,11 @@ struct NotchMenuView: View {
7070
if hooksInstalled {
7171
HookInstaller.uninstall()
7272
hooksInstalled = false
73+
AppSettings.hooksConsentGiven = false
7374
} else {
7475
HookInstaller.installIfNeeded()
7576
hooksInstalled = true
77+
AppSettings.hooksConsentGiven = true
7678
}
7779
}
7880

0 commit comments

Comments
 (0)