diff --git a/Sources/AskKeyAppKit/CodexOnboardingSetup.swift b/Sources/AskKeyAppKit/CodexOnboardingSetup.swift index 5329fb5..9877ef6 100644 --- a/Sources/AskKeyAppKit/CodexOnboardingSetup.swift +++ b/Sources/AskKeyAppKit/CodexOnboardingSetup.swift @@ -32,6 +32,7 @@ enum CodexOnboardingSetup { let state: CodexNativeHookStatus do { state = try native.status() + try mcp.verifyCommandDiscoveryHelper() } catch { if RestrictedProcessCancellation.current?() == true { throw AgentOnboardingFailure.cancelled } return AgentCheckReport( @@ -78,6 +79,7 @@ enum CodexOnboardingSetup { throw AgentOnboardingFailure.planChanged } guard try native.status() != .unsupported else { throw AgentOnboardingFailure.unsupportedVersion } + try mcp.verifyCommandDiscoveryHelper() if plan.configurationPresent { guard mcp.status() == .connected else { throw AgentOnboardingFailure.verificationFailed } } else { diff --git a/Sources/AskKeyHelper/CommandDiscoveryHook.swift b/Sources/AskKeyHelper/CommandDiscoveryHook.swift index 1dde5e9..c90af51 100644 --- a/Sources/AskKeyHelper/CommandDiscoveryHook.swift +++ b/Sources/AskKeyHelper/CommandDiscoveryHook.swift @@ -4,7 +4,7 @@ import AskKeyBroker /// Adapts native command hooks to the same SSH discovery reminder as Codex. /// It never executes tool input or accesses credentials. enum CommandDiscoveryHook { - /// Claude sends one JSON document terminated by EOF, rather than an MCP + /// Claude and Codex send one JSON document terminated by EOF, rather than an MCP /// line. Bound its total size before attempting to decode the envelope. static func readClaudeInput(maximumBytes: Int) -> Data? { var bytes = Data() @@ -63,13 +63,26 @@ enum CommandDiscoveryHook { event = rawEvent == "PreToolUse" ? "pre" : "post" session = rawSession; turn = nil; tool = rawTool call = rawCall; arguments = rawArguments + } else if client == "codex" { + // Codex 0.160.0 exposes explicit turn IDs and only successful + // PostToolUse callbacks. Failed calls use the no-progress release. + guard let rawEvent = input["hook_event_name"] as? String, + ["PreToolUse", "PostToolUse"].contains(rawEvent), + let rawSession = input["session_id"] as? String, !rawSession.isEmpty, + let rawTurn = input["turn_id"] as? String, !rawTurn.isEmpty, + let rawTool = input["tool_name"] as? String, + let rawCall = input["tool_use_id"] as? String, !rawCall.isEmpty, + let rawArguments = input["tool_input"] as? [String: Any] else { return allowed } + event = rawEvent == "PreToolUse" ? "pre" : "post" + session = rawSession; turn = rawTurn; tool = rawTool + call = rawCall; arguments = rawArguments } else { return allowed } // Cursor's generic event exposes MCP:, without a // server identifier. This is a discovery reminder, not authentication. let catalog = client == "cursor" ? tool == "MCP:list_credentials" - : tool == (client == "claude" ? "mcp__askkey__list_credentials" : "askkey__list_credentials") + : tool == (["claude", "codex"].contains(client) ? "mcp__askkey__list_credentials" : "askkey__list_credentials") let shell = client == "cursor" ? tool == "Shell" - : tool == (client == "claude" ? "Bash" : "run_terminal_command") + : tool == (["claude", "codex"].contains(client) ? "Bash" : "run_terminal_command") guard shell || catalog else { return allowed } if shell { guard event == "pre", let command = arguments["command"] as? String, @@ -80,7 +93,7 @@ enum CommandDiscoveryHook { callID: call, phase: event == "pre" ? .before : .after, catalog: catalog ) guard event == "pre", shell, !settled else { return allowed } - if client == "grok" || client == "claude" { + if ["grok", "claude", "codex"].contains(client) { return ["hookSpecificOutput": ["hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reminder]] } diff --git a/Sources/AskKeyHelper/DiscoveryTurnStore.swift b/Sources/AskKeyHelper/DiscoveryTurnStore.swift index 2aa475d..2764d9c 100644 --- a/Sources/AskKeyHelper/DiscoveryTurnStore.swift +++ b/Sources/AskKeyHelper/DiscoveryTurnStore.swift @@ -90,13 +90,18 @@ struct DiscoveryTurnStore { var entry = state.turns[key] ?? Turn(touched: now) if state.turns[key] == nil { state.turns[key] = entry } if catalog, let callID, !callID.isEmpty, let call { + let progressed: Bool switch phase { case .before: if entry.pending.count < 64 { entry.pending.insert(call) } + progressed = true case .after: - if entry.pending.remove(call) != nil { entry.completed = true } + progressed = entry.pending.remove(call) != nil + if progressed { entry.completed = true } } - entry.touched = now + // Codex's unmatched callbacks are not catalog progress and + // must not extend its lost-callback deadline. + if client != "codex" || progressed { entry.touched = now } state.turns[key] = entry } if state.turns.count > 256 { diff --git a/Sources/AskKeyHelper/main.swift b/Sources/AskKeyHelper/main.swift index 8bff183..8d4f95e 100644 --- a/Sources/AskKeyHelper/main.swift +++ b/Sources/AskKeyHelper/main.swift @@ -8,13 +8,13 @@ do { if command == "hook" { guard arguments.count == 2 else { throw HelperError.usage } if arguments[1] == "capabilities" { - try writeMCPResponse(["protocolVersion": 1, "clients": ["cursor", "grok", "claude"]]) + try writeMCPResponse(["protocolVersion": 1, "clients": ["cursor", "grok", "claude", "codex"]]) exit(EXIT_SUCCESS) } - guard ["cursor", "grok", "claude"].contains(arguments[1]) else { throw HelperError.usage } + guard ["cursor", "grok", "claude", "codex"].contains(arguments[1]) else { throw HelperError.usage } var response = CommandDiscoveryHook.allow(client: arguments[1]) let data: Data? - if arguments[1] == "claude" { + if arguments[1] == "claude" || arguments[1] == "codex" { data = CommandDiscoveryHook.readClaudeInput(maximumBytes: BrokerLimits.maximumFrameBytes) } else if case .frame(let frame) = readMCPFrame(maximumBytes: BrokerLimits.maximumFrameBytes) { data = frame @@ -25,7 +25,7 @@ do { // state cannot authorize a credential or block unrelated work. response = (try? CommandDiscoveryHook.response(client: arguments[1], input: input)) ?? response } - if arguments[1] != "claude" || !response.isEmpty { try writeMCPResponse(response) } + if !["claude", "codex"].contains(arguments[1]) || !response.isEmpty { try writeMCPResponse(response) } exit(EXIT_SUCCESS) } if command == "mcp" { diff --git a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Configuration.swift b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Configuration.swift index 37a537d..7a9d713 100644 --- a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Configuration.swift +++ b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Configuration.swift @@ -5,6 +5,11 @@ extension CodexDiscoveryHookConfiguration { var hooksParentURL: URL { hooksURL.deletingLastPathComponent() } static var expectedHookGroup: [String: Any] { + ["matcher": expectedMatcher, + "hooks": [["type": "command", "command": expectedCommand, "timeout": 3]]] + } + + static var legacyHookGroup: [String: Any] { [ "matcher": expectedMatcher, "hooks": [[ @@ -81,7 +86,7 @@ extension CodexDiscoveryHookConfiguration { guard let groups = rawGroups as? [Any] else { throw CodexDiscoveryHookConfigurationError.invalidHooksFile } - for rawGroup in groups { + for (index, rawGroup) in groups.enumerated() { guard let group = rawGroup as? [String: Any], let rawHooksInGroup = group["hooks"] as? [Any] else { throw CodexDiscoveryHookConfigurationError.invalidHooksFile @@ -90,11 +95,12 @@ extension CodexDiscoveryHookConfiguration { guard let hook = rawHook as? [String: Any] else { throw CodexDiscoveryHookConfigurationError.invalidHooksFile } - guard hook["server"] as? String == Self.expectedServer, - hook["tool"] as? String == Self.expectedTool else { + let legacy = hook["server"] as? String == Self.expectedServer + && hook["tool"] as? String == Self.expectedTool + guard legacy || Self.isOwnCommand(hook["command"] as? String) else { continue } - matches.append(HookGroupMatch(eventName: eventName, group: group)) + matches.append(HookGroupMatch(eventName: eventName, group: group, index: index, legacy: legacy)) } } } @@ -102,12 +108,25 @@ extension CodexDiscoveryHookConfiguration { } func validateOwnHook(_ matches: [HookGroupMatch]) throws { - if matches.count > 1 { + let legacy = matches.filter(\.legacy) + if (!legacy.isEmpty && matches.count > 1) + || Set(matches.map(\.eventName)).count != matches.count { throw CodexDiscoveryHookConfigurationError.multipleExpectedHooks } + for match in matches { + guard match.legacy ? (match.eventName == "PreToolUse" && jsonEqual(match.group, Self.legacyHookGroup)) + : (Self.expectedEvents.contains(match.eventName) && jsonEqual(match.group, Self.expectedHookGroup)) else { + throw CodexDiscoveryHookConfigurationError.customHookMismatch + } + } } - func appendExpectedHook(to document: inout [String: Any]) throws { + static func isOwnCommand(_ command: String?) -> Bool { + guard let command else { return false } + return command == expectedCommand || (command.contains("askkey") && command.contains("hook codex")) + } + + func appendExpectedHook(to document: inout [String: Any], matches: [HookGroupMatch]) throws { var hooks: [String: Any] if let rawHooks = document["hooks"] { guard let existingHooks = rawHooks as? [String: Any] else { @@ -118,17 +137,15 @@ extension CodexDiscoveryHookConfiguration { hooks = [:] } - var preToolUse: [Any] - if let rawPreToolUse = hooks["PreToolUse"] { - guard let existing = rawPreToolUse as? [Any] else { - throw CodexDiscoveryHookConfigurationError.invalidHooksFile + for event in Self.expectedEvents { + var groups = hooks[event] as? [Any] ?? [] + if let match = matches.first(where: { $0.eventName == event }) { + if match.legacy { groups[match.index] = Self.expectedHookGroup } + } else { + groups.append(Self.expectedHookGroup) } - preToolUse = existing - } else { - preToolUse = [] + hooks[event] = groups } - preToolUse.append(Self.expectedHookGroup) - hooks["PreToolUse"] = preToolUse document["hooks"] = hooks } diff --git a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+HookGroupMatch.swift b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+HookGroupMatch.swift index 544ad79..cbfdbec 100644 --- a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+HookGroupMatch.swift +++ b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+HookGroupMatch.swift @@ -5,5 +5,7 @@ extension CodexDiscoveryHookConfiguration { struct HookGroupMatch { let eventName: String let group: [String: Any] + let index: Int + let legacy: Bool } } diff --git a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Plan.swift b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Plan.swift index 4f26b23..cbe96c2 100644 --- a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Plan.swift +++ b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Plan.swift @@ -12,12 +12,7 @@ extension CodexDiscoveryHookConfiguration { let matches = try matchingHookGroups(in: document) try validateOwnHook(matches) - if let own = matches.first { - let expected = Self.expectedHookGroup - guard own.eventName == "PreToolUse", - jsonEqual(own.group, expected) else { - throw CodexDiscoveryHookConfigurationError.customHookMismatch - } + if matches.count == Self.expectedEvents.count && matches.allSatisfy({ !$0.legacy }) { return CodexDiscoveryHookPlan( before: snapshot?.bytes, after: snapshot?.bytes, @@ -29,14 +24,14 @@ extension CodexDiscoveryHookConfiguration { } var next = document - try appendExpectedHook(to: &next) + try appendExpectedHook(to: &next, matches: matches) let after = try serializeDocument(next) let afterMode = snapshot?.mode ?? 0o600 return CodexDiscoveryHookPlan( before: snapshot?.bytes, after: after, changed: true, - summary: "Add Ask Key's Codex discovery hook.", + summary: "Install Ask Key's Codex command discovery hooks.", beforeMode: snapshot?.mode, afterMode: afterMode ) diff --git a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Verification.swift b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Verification.swift index 0166e40..abc6aeb 100644 --- a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Verification.swift +++ b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration+Verification.swift @@ -10,10 +10,6 @@ extension CodexDiscoveryHookConfiguration { let document = try parseDocument(snapshot.bytes) let matches = try matchingHookGroups(in: document) try validateOwnHook(matches) - guard let own = matches.first else { return false } - guard own.eventName == "PreToolUse", jsonEqual(own.group, Self.expectedHookGroup) else { - throw CodexDiscoveryHookConfigurationError.customHookMismatch - } - return true + return matches.count == Self.expectedEvents.count && matches.allSatisfy { !$0.legacy } } } diff --git a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration.swift b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration.swift index 21a5f91..8e8b99b 100644 --- a/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration.swift +++ b/Sources/AskKeyIntegrations/CodexDiscoveryHookConfiguration.swift @@ -11,6 +11,8 @@ public final class CodexDiscoveryHookConfiguration: @unchecked Sendable { static let expectedMatcher = "^(Bash|mcp__askkey__list_credentials)$" static let expectedServer = "askkey" static let expectedTool = "credential_discovery_guard" + static let expectedCommand = "\"/Applications/Ask Key.app/Contents/Helpers/askkey\" hook codex" + static let expectedEvents = ["PreToolUse", "PostToolUse"] let hooksURL: URL let backupDirectory: URL diff --git a/Sources/AskKeyIntegrations/CodexNativeHookClient.swift b/Sources/AskKeyIntegrations/CodexNativeHookClient.swift index a7d6ef1..8d15c5a 100644 --- a/Sources/AskKeyIntegrations/CodexNativeHookClient.swift +++ b/Sources/AskKeyIntegrations/CodexNativeHookClient.swift @@ -47,12 +47,17 @@ public struct CodexNativeHookClient: Sendable { } let original = try safeConfig() let before = try userLayer(rpc) - guard let metadata = try hookMetadata(rpc, snapshot: reviewed), - let key = metadata["key"] as? String, - let hash = metadata["currentHash"] as? String else { - throw CodexNativeHookClientError.invalidResponse - } + let metadata = try hookMetadata(rpc, snapshot: reviewed) if Self.state(metadata) == .enabled { return .enabled } + var value: [String: [String: Any]] = [:] + for own in metadata { + guard let key = own["key"] as? String, let hash = own["currentHash"] as? String else { + throw CodexNativeHookClientError.invalidResponse + } + value[key] = ["enabled": true, "trusted_hash": hash] + } + let keys = Set(value.keys) + guard keys.count == 2 else { throw CodexNativeHookClientError.invalidResponse } let directory = backupDirectory ?? userHome.appendingPathComponent( "Library/Application Support/AskKey/client-backups/codex-trust" ) @@ -80,14 +85,16 @@ public struct CodexNativeHookClient: Sendable { "filePath": configURL.path, "expectedVersion": before.version, "reloadUserConfig": true, - "edits": [["keyPath": "hooks.state", "mergeStrategy": "upsert", "value": [ - key: ["enabled": true, "trusted_hash": hash] - ]]] + "edits": [["keyPath": "hooks.state", "mergeStrategy": "upsert", "value": value]] ]) let after = try userLayer(rpc) - guard try Self.normalized(before.config, removing: key) == Self.normalized(after.config, removing: key), - let readback = try hookMetadata(rpc, snapshot: reviewed), - readback["currentHash"] as? String == hash, + let readback = try hookMetadata(rpc, snapshot: reviewed) + guard try Self.normalized(before.config, removing: keys) == Self.normalized(after.config, removing: keys), + readback.count == metadata.count, + zip(metadata, readback).allSatisfy({ + $0["key"] as? String == $1["key"] as? String + && $0["currentHash"] as? String == $1["currentHash"] as? String + }), Self.state(readback) == .enabled else { throw CodexNativeHookClientError.verificationFailed } @@ -102,7 +109,6 @@ public struct CodexNativeHookClient: Sendable { private struct Snapshot: Equatable { let bytes: Data - let key: String } private func reviewedSnapshot() throws -> Snapshot? { @@ -113,63 +119,74 @@ public struct CodexNativeHookClient: Sendable { ) guard try configuration.hasExpectedHook() else { return nil } let bytes = try ClientConfigFileIO.readRegularFile(hooksURL, maximumBytes: 1_048_576).bytes - guard let root = try JSONSerialization.jsonObject(with: bytes) as? [String: Any], - let hooks = root["hooks"] as? [String: Any], - let groups = hooks["PreToolUse"] as? [[String: Any]], - let index = groups.firstIndex(where: { group in - guard let handlers = group["hooks"] as? [[String: Any]] else { return false } - return handlers.contains { $0["server"] as? String == "askkey" - && $0["tool"] as? String == "credential_discovery_guard" } - }), - try configuration.hasExpectedHook(), + guard try configuration.hasExpectedHook(), try ClientConfigFileIO.readRegularFile(hooksURL).bytes == bytes else { throw CodexNativeHookClientError.configurationChanged } - return Snapshot(bytes: bytes, key: "\(hooksURL.path):pre_tool_use:\(index):0") + return Snapshot(bytes: bytes) } - private func hookMetadata(_ rpc: RPC, snapshot: Snapshot?) throws -> [String: Any]? { - let result = try rpc.call("hooks/list", ["cwds": [userHome.path]]) - guard let entries = result["data"] as? [[String: Any]], entries.count == 1, + private func hookMetadata(_ rpc: RPC, snapshot: Snapshot?) throws -> [[String: Any]] { + let response = try rpc.call("hooks/list", ["cwds": [userHome.path]]) + guard let entries = response["data"] as? [[String: Any]], entries.count == 1, let entry = entries.first, let errors = entry["errors"] as? [Any], errors.isEmpty, let hooks = entry["hooks"] as? [[String: Any]] else { throw CodexNativeHookClientError.invalidResponse } + guard let snapshot else { + guard try reviewedSnapshot() == nil else { + throw CodexNativeHookClientError.configurationChanged + } + return [] + } + // Identify exact definitions, then use Codex's returned keys/hashes. + // Unrelated groups can appear before or after ours in either event. let matches = hooks.filter { $0["sourcePath"] as? String == hooksURL.path - && $0["server"] as? String == "askkey" - && $0["tool"] as? String == "credential_discovery_guard" + && CodexDiscoveryHookConfiguration.isOwnCommand($0["command"] as? String) } - guard let snapshot else { - guard matches.isEmpty, try reviewedSnapshot() == nil else { - throw CodexNativeHookClientError.configurationChanged + var result: [[String: Any]] = [] + for event in ["preToolUse", "postToolUse"] { + let candidates = matches.filter { $0["eventName"] as? String == event } + guard candidates.count == 1, let own = candidates.first, + let key = own["key"] as? String, !key.isEmpty, + Self.validKey(key, sourcePath: hooksURL.path, event: event), + own["source"] as? String == "user", + own["isManaged"] as? Bool == false, + own["handlerType"] as? String == "command", + own["command"] as? String == CodexDiscoveryHookConfiguration.expectedCommand, + own["async"] as? Bool == false, + own["matcher"] as? String == CodexDiscoveryHookConfiguration.expectedMatcher, + own["timeoutSec"] as? Int == 3, + let hash = own["currentHash"] as? String, + hash.hasPrefix("sha256:"), hash.count == 71, + hash.dropFirst(7).allSatisfy({ $0.isHexDigit }), + own["enabled"] is Bool, + ["trusted", "untrusted", "modified"].contains(own["trustStatus"] as? String ?? "") else { + throw CodexNativeHookClientError.verificationFailed } - return nil + result.append(own) } - guard matches.count == 1, let own = matches.first, - own["key"] as? String == snapshot.key, - own["source"] as? String == "user", - own["isManaged"] as? Bool == false, - own["eventName"] as? String == "preToolUse", - own["handlerType"] as? String == "mcpTool", - own["matcher"] as? String == "^(Bash|mcp__askkey__list_credentials)$", - own["timeoutSec"] as? Int == 3, - let hash = own["currentHash"] as? String, - hash.hasPrefix("sha256:"), hash.count == 71, - hash.dropFirst(7).allSatisfy({ $0.isHexDigit }), - own["enabled"] is Bool, - ["trusted", "untrusted"].contains(own["trustStatus"] as? String ?? ""), + guard matches.count == 2, Set(result.compactMap { $0["key"] as? String }).count == 2, try reviewedSnapshot()?.bytes == snapshot.bytes else { throw CodexNativeHookClientError.verificationFailed } - return own + return result + } + + private static func validKey(_ key: String, sourcePath: String, event: String) -> Bool { + let eventKey = event == "preToolUse" ? "pre_tool_use" : "post_tool_use" + let prefix = "\(sourcePath):\(eventKey):" + guard key.hasPrefix(prefix) else { return false } + let indices = key.dropFirst(prefix.count).split(separator: ":", omittingEmptySubsequences: false) + return indices.count == 2 && indices.allSatisfy { Int($0).map { $0 >= 0 } == true } } - private static func state(_ metadata: [String: Any]?) -> CodexNativeHookStatus { - guard let metadata else { return .missing } - if metadata["trustStatus"] as? String != "trusted" { return .untrusted } - return metadata["enabled"] as? Bool == true ? .enabled : .disabled + private static func state(_ metadata: [[String: Any]]) -> CodexNativeHookStatus { + guard metadata.count == 2 else { return .missing } + if metadata.contains(where: { $0["trustStatus"] as? String != "trusted" }) { return .untrusted } + return metadata.allSatisfy { $0["enabled"] as? Bool == true } ? .enabled : .disabled } private func userLayer(_ rpc: RPC) throws -> (version: String, config: [String: Any]) { @@ -195,11 +212,11 @@ public struct CodexNativeHookClient: Sendable { } catch { throw CodexNativeHookClientError.unsafeConfiguration } } - private static func normalized(_ input: [String: Any], removing key: String) throws -> Data { + private static func normalized(_ input: [String: Any], removing keys: Set) throws -> Data { var config = input if var hooks = config["hooks"] as? [String: Any] { if var state = hooks["state"] as? [String: Any] { - state.removeValue(forKey: key) + for key in keys { state.removeValue(forKey: key) } if state.isEmpty { hooks.removeValue(forKey: "state") } else { hooks["state"] = state } } if hooks.isEmpty { config.removeValue(forKey: "hooks") } else { config["hooks"] = hooks } diff --git a/Sources/AskKeyIntegrations/CodexUserMCPAdapter+Verification.swift b/Sources/AskKeyIntegrations/CodexUserMCPAdapter+Verification.swift index f8c5416..a2f2759 100644 --- a/Sources/AskKeyIntegrations/CodexUserMCPAdapter+Verification.swift +++ b/Sources/AskKeyIntegrations/CodexUserMCPAdapter+Verification.swift @@ -54,13 +54,23 @@ extension CodexUserMCPAdapter { throw CodexUserMCPError.connectionFailed("broker") } try verifyHelperMCP() + if requiresCredentialDiscovery { try verifyCommandDiscoveryHelper() } } - private func verifyHelperMCP() throws { - var identity = MCPHelperContract.Identity.askKeyHelper - if requiresCredentialDiscovery { - identity.requiredTools.insert("credential_discovery_guard") + public func verifyCommandDiscoveryHelper() throws { + try assertTrustedHelper() + let response = try runProcess(executable: helperURL, arguments: ["hook", "capabilities"], + environment: ProcessInfo.processInfo.environment) + guard let data = response.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + json["protocolVersion"] as? Int == 1, + let clients = json["clients"] as? [String], clients.contains("codex") else { + throw CodexUserMCPError.connectionFailed("helper") } + } + + private func verifyHelperMCP() throws { + let identity = MCPHelperContract.Identity.askKeyHelper var environment = ProcessInfo.processInfo.environment environment["ASKKEY_BROKER_SOCKET"] = brokerSocketPath let response: String diff --git a/Tests/AskKeyAppTests/CodexOnboardingSetupTests.swift b/Tests/AskKeyAppTests/CodexOnboardingSetupTests.swift index 3e01122..31a6852 100644 --- a/Tests/AskKeyAppTests/CodexOnboardingSetupTests.swift +++ b/Tests/AskKeyAppTests/CodexOnboardingSetupTests.swift @@ -24,6 +24,39 @@ final class CodexOnboardingSetupTests: AskKeyAppTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.hooksURL.path)) } + func testLegacyAndPartialCommandHooksOfferReconnectWithoutWriting() throws { + let command: [String: Any] = ["matcher": "^(Bash|mcp__askkey__list_credentials)$", "hooks": [[ + "type": "command", "command": "\"/Applications/Ask Key.app/Contents/Helpers/askkey\" hook codex", "timeout": 3 + ]]] + let legacy: [String: Any] = ["matcher": "^(Bash|mcp__askkey__list_credentials)$", "hooks": [[ + "type": "mcp_tool", "server": "askkey", "tool": "credential_discovery_guard", "timeout": 3, + "input": ["session_id": "${session_id}", "turn_id": "${turn_id}", + "tool_name": "${tool_name}", "tool_input": "${tool_input}"] + ]]] + for events in [["PreToolUse": [legacy]], ["PreToolUse": [command]], ["PostToolUse": [command]]] { + let fixture = try CodexSetupFixture(nativeMode: .missing) + defer { fixture.close() } + _ = try fixture.mcp.apply() + let original = try JSONSerialization.data(withJSONObject: ["hooks": events], options: [.sortedKeys]) + try original.write(to: fixture.hooksURL) + let mcpBefore = try Data(contentsOf: fixture.mcp.configURL) + let checked = try CodexOnboardingSetup.check( + mcp: fixture.mcp, hook: fixture.hook, native: fixture.native, plan: fixture.plan) + XCTAssertEqual(checked.outcome, .configuredUnverified) + XCTAssertEqual(checked.discovery, .missing) + XCTAssertTrue(try XCTUnwrap(checked.plan?.codexHookPlan).changed) + XCTAssertEqual(try Data(contentsOf: fixture.hooksURL), original) + XCTAssertEqual(try Data(contentsOf: fixture.mcp.configURL), mcpBefore) + // A reused legacy key carries an old hash, reported as modified. + try JSONSerialization.data(withJSONObject: ["enabled": false, "trusted": "modified"]) + .write(to: fixture.root.appendingPathComponent("native-state.json")) + let applied = try CodexOnboardingSetup.apply(mcp: fixture.mcp, hook: fixture.hook, + native: fixture.native, plan: XCTUnwrap(checked.plan)) + XCTAssertEqual(applied.outcome, .verifiedConnected) + XCTAssertEqual(try fixture.native.status(), .enabled) + } + } + func testChangingHookAfterReviewStopsBeforeMCPConfiguration() throws { let fixture = try CodexSetupFixture() defer { fixture.close() } @@ -295,39 +328,42 @@ private final class CodexSetupFixture { def save(value): state_path.write_text(json.dumps(value)) - def own_index(): + def own_indices(event): try: document = json.loads(hooks.read_text()) except Exception: - return None - groups = document.get("hooks", {}).get("PreToolUse", []) + return [] + groups = document.get("hooks", {}).get(event, []) + result = [] for index, group in enumerate(groups): for handler in group.get("hooks", []): - if handler.get("server") == "askkey" and handler.get("tool") == "credential_discovery_guard": - return index - return None + if handler.get("command", "").endswith(" hook codex"): + result.append(index) + return result def metadata(): - index = own_index() - if index is None: - return [] value = state() - return [{ - "key": str(hooks) + ":pre_tool_use:" + str(index) + ":0", + result = [] + for raw, event, event_key in [("PreToolUse", "preToolUse", "pre_tool_use"), + ("PostToolUse", "postToolUse", "post_tool_use")]: + for index in own_indices(raw): + result.append({ + "key": str(hooks) + ":" + event_key + ":" + str(index) + ":0", "currentHash": current_hash, "enabled": value["enabled"], - "eventName": "preToolUse", + "eventName": event, "isManaged": False, "matcher": "^(Bash|mcp__askkey__list_credentials)$", "source": "user", "sourcePath": str(hooks), "timeoutSec": 3, "trustStatus": value["trusted"], - "handlerType": "mcpTool", - "server": "askkey", - "tool": "credential_discovery_guard", + "handlerType": "command", + "command": '\"/Applications/Ask Key.app/Contents/Helpers/askkey\" hook codex', + "async": False, "displayOrder": index - }] + }) + return result def config_read(): return { diff --git a/Tests/AskKeyBrokerTests/CodexCommandDiscoveryHookTests.swift b/Tests/AskKeyBrokerTests/CodexCommandDiscoveryHookTests.swift new file mode 100644 index 0000000..217a45b --- /dev/null +++ b/Tests/AskKeyBrokerTests/CodexCommandDiscoveryHookTests.swift @@ -0,0 +1,173 @@ +import Foundation +import XCTest +@testable import AskKeyBroker + +final class CodexCommandDiscoveryHookTests: XCTestCase { + func testBoundedMultilineDocumentDeniesSSHWithoutRunningToolInput() throws { + let fixture = try Fixture() + let marker = fixture.root.appendingPathComponent("must-not-exist") + let envelope = fixture.envelope("PreToolUse", command: "ssh example.invalid; touch '\(marker.path)'") + let output = try fixture.call(JSONSerialization.data(withJSONObject: envelope, options: [.prettyPrinted])) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: output) as? [String: Any]) + let specific = try XCTUnwrap(response["hookSpecificOutput"] as? [String: Any]) + XCTAssertEqual(specific["hookEventName"] as? String, "PreToolUse") + XCTAssertEqual(specific["permissionDecision"] as? String, "deny") + XCTAssertTrue((specific["permissionDecisionReason"] as? String ?? "").contains("does not authorize")) + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + let stored = try String(contentsOf: fixture.stateURL, encoding: .utf8) + for privateInput in ["synthetic-session", "synthetic-turn", "example.invalid", marker.path] { + XCTAssertFalse(stored.contains(privateInput)) + } + } + + func testOnlyMatchingCatalogPostSettlesTheSameSessionAndTurn() throws { + let fixture = try Fixture() + XCTAssertTrue(try fixture.event("PostToolUse", tool: Fixture.catalog, call: "lookup").isEmpty) + XCTAssertTrue(try fixture.denied()) + XCTAssertTrue(try fixture.event("PreToolUse", tool: Fixture.catalog, call: "lookup").isEmpty) + XCTAssertTrue(try fixture.denied(), "Scheduling catalog progress is not completion") + _ = try fixture.event("PostToolUse", tool: Fixture.catalog, call: "lookup", session: "other-session") + _ = try fixture.event("PostToolUse", tool: Fixture.catalog, call: "lookup", turn: "other-turn") + _ = try fixture.event("PostToolUse", tool: Fixture.catalog, call: "other-call") + XCTAssertTrue(try fixture.denied()) + XCTAssertTrue(try fixture.event("PostToolUse", tool: Fixture.catalog, call: "lookup").isEmpty) + XCTAssertFalse(try fixture.denied()) + XCTAssertTrue(try fixture.denied(turn: "next-turn")) + XCTAssertTrue(try fixture.denied(session: "other-session")) + } + + func testLateCallbackCannotSettleANewerTurnWithTheSameCallID() throws { + let fixture = try Fixture() + _ = try fixture.event("PreToolUse", tool: Fixture.catalog, call: "lookup", turn: "old-turn") + _ = try fixture.event("PreToolUse", tool: Fixture.catalog, call: "lookup", turn: "next-turn") + _ = try fixture.event("PostToolUse", tool: Fixture.catalog, call: "lookup", turn: "old-turn") + XCTAssertTrue(try fixture.denied(turn: "next-turn")) + XCTAssertFalse(try fixture.denied(turn: "old-turn")) + } + + func testFailedOrMissingCallbackReleasesAfterNoProgressWithoutMarkingCompleted() throws { + for pending in [false, true] { + let fixture = try Fixture() + XCTAssertTrue(try fixture.denied()) + if pending { + _ = try fixture.event("PreToolUse", tool: Fixture.catalog, call: "lost") + // 0.160.0 has no failure callback; this unknown event is ignored. + _ = try fixture.event("PostToolUseFailure", tool: Fixture.catalog, call: "lost") + } + XCTAssertTrue(try fixture.denied()) + try fixture.ageState() + _ = try fixture.event("PostToolUse", tool: Fixture.catalog, call: "unmatched") + XCTAssertFalse(try fixture.denied()) + let state = try fixture.state() + let turns = try XCTUnwrap(state["turns"] as? [String: [String: Any]]) + XCTAssertTrue(turns.values.allSatisfy { $0["completed"] as? Bool == false }) + _ = try fixture.event("PreToolUse", tool: Fixture.catalog, call: "new-progress") + XCTAssertTrue(try fixture.denied(), "A new catalog attempt resets the progress deadline") + } + } + + func testMalformedOversizedAndUnrelatedInputAllowsWithNoOutputOrState() throws { + let fixture = try Fixture() + for raw in ["", "{", "[]", "null", "{}", "{}\n{}"] { + XCTAssertTrue(try fixture.call(Data(raw.utf8)).isEmpty) + } + for key in ["session_id", "turn_id", "tool_use_id", "tool_name", "tool_input", "hook_event_name"] { + var envelope = fixture.envelope("PreToolUse") + envelope.removeValue(forKey: key) + XCTAssertTrue(try fixture.call(JSONSerialization.data(withJSONObject: envelope)).isEmpty) + } + for key in ["session_id", "turn_id", "tool_use_id"] { + for invalid in ["", String(repeating: "x", count: 4097)] { + var envelope = fixture.envelope("PreToolUse") + envelope[key] = invalid + XCTAssertTrue(try fixture.call(JSONSerialization.data(withJSONObject: envelope)).isEmpty) + } + } + var oversized = fixture.envelope("PreToolUse") + oversized["padding"] = String(repeating: "x", count: BrokerLimits.maximumFrameBytes) + XCTAssertTrue(try fixture.call(JSONSerialization.data(withJSONObject: oversized)).isEmpty) + for tool in ["shell", "exec_command", "mcp__other__list_credentials", "Read"] { + XCTAssertTrue(try fixture.event("PreToolUse", tool: tool).isEmpty) + } + for command in ["ls", "ssh -V", "ssh -G example.invalid", "echo ssh example.invalid"] { + XCTAssertTrue(try fixture.event("PreToolUse", command: command).isEmpty) + } + for event in ["UserPromptSubmit", "PostToolUseFailure", "SessionStart", "unknown", "PostToolUse"] { + XCTAssertTrue(try fixture.event(event).isEmpty) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.stateURL.path)) + } + + func testHookCapabilitiesAdvertiseCodexWithoutContactingBroker() throws { + let fixture = try Fixture() + let output = try fixture.call(Data(), arguments: ["hook", "capabilities"]) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: output) as? [String: Any]) + XCTAssertEqual(response["protocolVersion"] as? Int, 1) + XCTAssertEqual(Set(try XCTUnwrap(response["clients"] as? [String])), ["cursor", "grok", "claude", "codex"]) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.stateURL.path)) + } + + private final class Fixture { + static let catalog = "mcp__askkey__list_credentials" + let root: URL + var stateURL: URL { root.appendingPathComponent("credential-discovery/state.json") } + init() throws { + let candidate = FileManager.default.temporaryDirectory.appendingPathComponent("askkey-codex-hook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + root = try physicalTestDirectory(candidate) + } + deinit { try? FileManager.default.removeItem(at: root) } + func envelope(_ phase: String, tool: String = "Bash", call: String = "ssh", + session: String = "synthetic-session", turn: String = "synthetic-turn", + command: String = "ssh example.invalid uptime") -> [String: Any] { + ["hook_event_name": phase, "session_id": session, "turn_id": turn, "tool_name": tool, + "tool_use_id": call, "tool_input": ["command": command], "tool_response": [:]] + } + func event(_ phase: String, tool: String = "Bash", call: String = "ssh", + session: String = "synthetic-session", turn: String = "synthetic-turn", + command: String = "ssh example.invalid uptime") throws -> [String: Any] { + let data = try self.call(JSONSerialization.data(withJSONObject: + envelope(phase, tool: tool, call: call, session: session, turn: turn, command: command))) + if data.isEmpty { return [:] } + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertFalse(response.isEmpty, "Allowed Codex hooks must emit no output") + return response + } + func denied(session: String = "synthetic-session", turn: String = "synthetic-turn") throws -> Bool { + (try event("PreToolUse", session: session, turn: turn)["hookSpecificOutput"] as? [String: Any])?["permissionDecision"] as? String == "deny" + } + func state() throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + } + func ageState() throws { + var state = try state() + var turns = try XCTUnwrap(state["turns"] as? [String: [String: Any]]) + for key in turns.keys { turns[key]?["touched"] = Date().timeIntervalSince1970 - 31 } + state["turns"] = turns + try JSONSerialization.data(withJSONObject: state).write(to: stateURL) + } + func call(_ data: Data, arguments: [String] = ["hook", "codex"]) throws -> Data { + let process = Process() + process.executableURL = Bundle(for: CodexCommandDiscoveryHookTests.self).bundleURL + .deletingLastPathComponent().appendingPathComponent("askkey") + process.arguments = arguments + let environment = helperTestEnvironment(overrides: ["ASKKEY_DEBUG_RUN_DIRECTORY": root.path]) + _ = try DebugRunDirectory.resolve(environment: environment, homeDirectory: FileManager.default.homeDirectoryForCurrentUser) + process.environment = environment + let input = Pipe(), output = Pipe(), error = Pipe() + process.standardInput = input; process.standardOutput = output; process.standardError = error + let exited = XCTestExpectation(description: "Codex hook exited") + process.terminationHandler = { _ in exited.fulfill() } + try process.run() + defer { if process.isRunning { process.terminate() } } + try input.fileHandleForWriting.write(contentsOf: data) + try input.fileHandleForWriting.close() + XCTAssertEqual(XCTWaiter.wait(for: [exited], timeout: 3), .completed) + guard !process.isRunning else { throw CocoaError(.executableRuntimeMismatch) } + XCTAssertEqual(process.terminationStatus, 0) + XCTAssertTrue(error.fileHandleForReading.readDataToEndOfFile().isEmpty) + return output.fileHandleForReading.readDataToEndOfFile() + } + } +} diff --git a/Tests/AskKeyIntegrationsTests/CodexCommandHelperVerificationTests.swift b/Tests/AskKeyIntegrationsTests/CodexCommandHelperVerificationTests.swift new file mode 100644 index 0000000..22eb6ac --- /dev/null +++ b/Tests/AskKeyIntegrationsTests/CodexCommandHelperVerificationTests.swift @@ -0,0 +1,69 @@ +import Foundation +import XCTest +import AskKeyBroker +@testable import AskKeyIntegrations + +final class CodexCommandHelperVerificationTests: XCTestCase { + func testOldHelperWithoutCodexCommandCapabilityIsRejected() throws { + for response in [ + #"{"protocolVersion":1,"clients":["cursor","grok","claude"]}"#, + #"{"protocolVersion":2,"clients":["codex"]}"#, + "{}", "invalid" + ] { + let fixture = try Fixture(response: response) + XCTAssertThrowsError(try fixture.adapter.verifyCommandDiscoveryHelper()) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.adapter.configURL.path)) + } + } + + func testCapabilityProbeRequiresTrustedHelperAndNeverCallsMCPOrBroker() throws { + let fixture = try Fixture(response: #"{"protocolVersion":1,"clients":["codex"]}"#) + XCTAssertNoThrow(try fixture.adapter.verifyCommandDiscoveryHelper()) + XCTAssertEqual(try String(contentsOf: fixture.arguments, encoding: .utf8), "hook\ncapabilities\n") + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.adapter.configURL.path)) + let untrusted = try Fixture(response: #"{"protocolVersion":1,"clients":["codex"]}"#, trusted: false) + XCTAssertThrowsError(try untrusted.adapter.verifyCommandDiscoveryHelper()) + XCTAssertFalse(FileManager.default.fileExists(atPath: untrusted.arguments.path)) + } + + func testDiscoveryVerificationAcceptsMCPWithoutTheLegacyGuardTool() throws { + let fixture = try Fixture(response: #"{"protocolVersion":1,"clients":["codex"]}"#) + let broker = BrokerSocketServer(socketPath: fixture.adapter.brokerSocketPath, + handler: .init(catalog: { _ in [] }, requestStatus: { _, _ in nil })) + try broker.start() + defer { broker.stop() } + XCTAssertNoThrow(try fixture.adapter.verifyConnection(), "Command discovery does not require the legacy MCP guard") + } + + private final class Fixture { + let root: URL + let arguments: URL + let adapter: CodexUserMCPAdapter + init(response: String, trusted: Bool = true) throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("ak-cap-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + arguments = root.appendingPathComponent("arguments.txt") + let helper = root.appendingPathComponent("helper") + let script = """ + #!/bin/sh + printf '%s\\n' "$@" > '\(arguments.path.replacingOccurrences(of: "'", with: "'\\''"))' + if [ "$1" = "mcp" ]; then + cat >/dev/null + cat <<'MCP' + {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"askkey","version":"\(AskKeyVersion.current)"}}} + {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"list_credentials"},{"name":"run"}]}} + MCP + exit 0 + fi + printf '%s\\n' '\(response.replacingOccurrences(of: "'", with: "'\\''"))' + """ + try Data(script.utf8).write(to: helper) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helper.path) + adapter = CodexUserMCPAdapter(configURL: root.appendingPathComponent("config.toml"), + helperURL: helper, backupDirectory: root.appendingPathComponent("backups"), + brokerSocketPath: root.appendingPathComponent("nonexistent.sock").path, + signing: CodexHelperSigning { _ in trusted }, requiresCredentialDiscovery: true) + } + deinit { try? FileManager.default.removeItem(at: root) } + } +} diff --git a/Tests/AskKeyIntegrationsTests/CodexDiscoveryHookConfigurationTests.swift b/Tests/AskKeyIntegrationsTests/CodexDiscoveryHookConfigurationTests.swift index 02d0d81..b035de8 100644 --- a/Tests/AskKeyIntegrationsTests/CodexDiscoveryHookConfigurationTests.swift +++ b/Tests/AskKeyIntegrationsTests/CodexDiscoveryHookConfigurationTests.swift @@ -43,7 +43,7 @@ final class CodexDiscoveryHookConfigurationTests: XCTestCase { try Self.expectedHookGroup.asJSONData() ) let postToolUse = try XCTUnwrap(hooks["PostToolUse"] as? [[String: Any]]) - XCTAssertEqual(postToolUse.count, 1) + XCTAssertEqual(postToolUse.count, 2) XCTAssertEqual(postToolUse[0]["matcher"] as? String, "post") XCTAssertEqual(root["model"] as? String, "keep") } @@ -74,7 +74,8 @@ final class CodexDiscoveryHookConfigurationTests: XCTestCase { "PreToolUse": [ ["matcher": "before", "hooks": [["type": "command", "command": "/usr/bin/true"]]], Self.expectedHookGroup - ] + ], + "PostToolUse": [Self.expectedHookGroup] ] ] )) @@ -123,7 +124,7 @@ final class CodexDiscoveryHookConfigurationTests: XCTestCase { let root = try XCTUnwrap(try harness.readData().jsonObject() as? [String: Any]) let hooks = try XCTUnwrap(root["hooks"] as? [String: Any]) XCTAssertNotNil(hooks["PreToolUse"] as? [[String: Any]]) - XCTAssertEqual((hooks["PostToolUse"] as? [[String: Any]])?.count, 1) + XCTAssertEqual((hooks["PostToolUse"] as? [[String: Any]])?.count, 2) XCTAssertEqual(root["other"] as? Bool, true) } @@ -332,10 +333,73 @@ final class CodexDiscoveryHookConfigurationTests: XCTestCase { } XCTAssertEqual(try harness.readData(), harness.originalData) } + + func testExactLegacyMigratesInPlaceWithOneBackupAndPreservesMode() throws { + let unrelated: [String: Any] = ["matcher": "unrelated", "hooks": [["type": "command", "command": "true"]]] + let harness = try Harness(data: Self.jsonData(root: ["hooks": [ + "PreToolUse": [unrelated, Self.legacyHookGroup, unrelated], "PostToolUse": [unrelated] + ], "keep": false])) + try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: harness.hooksURL.path) + XCTAssertFalse(try harness.configuration.hasExpectedHook(), "Legacy needs reconnection") + let plan = try harness.configuration.preview() + try harness.configuration.apply(plan: plan) + let root = try XCTUnwrap(try harness.readData().jsonObject() as? [String: Any]) + let hooks = try XCTUnwrap(root["hooks"] as? [String: [[String: Any]]]) + XCTAssertEqual(try hooks["PreToolUse"]?.asJSONData(), try [unrelated, Self.expectedHookGroup, unrelated].asJSONData()) + XCTAssertEqual(try hooks["PostToolUse"]?.asJSONData(), try [unrelated, Self.expectedHookGroup].asJSONData()) + XCTAssertEqual(root["keep"] as? Bool, false) + XCTAssertEqual(try harness.mode(), 0o640) + XCTAssertEqual(try harness.backupFiles().count, 1) + XCTAssertEqual(try Data(contentsOf: XCTUnwrap(harness.backupFiles().first)), harness.originalData) + XCTAssertFalse(try harness.configuration.preview().changed) + } + + func testPartialCommandDefinitionNeedsReconnectAndAddsOnlyMissingEvent() throws { + for event in ["PreToolUse", "PostToolUse"] { + let harness = try Harness(data: Self.jsonData(root: ["hooks": [event: [Self.expectedHookGroup]]])) + XCTAssertFalse(try harness.configuration.hasExpectedHook()) + let plan = try harness.configuration.preview() + XCTAssertTrue(plan.changed) + try harness.configuration.apply(plan: plan) + XCTAssertTrue(try harness.configuration.hasExpectedHook()) + XCTAssertFalse(try harness.configuration.preview().changed) + } + } + + func testCustomizedLegacyAndCommandDefinitionsAreNeverRewritten() throws { + for group in [Self.legacyHookGroup, Self.expectedHookGroup] { + var customized = group + customized["matcher"] = "custom" + let harness = try Harness(data: Self.jsonData(root: ["hooks": ["PreToolUse": [customized]]])) + XCTAssertThrowsError(try harness.configuration.preview()) { error in + XCTAssertEqual(error as? CodexDiscoveryHookConfigurationError, .customHookMismatch) + } + XCTAssertEqual(try harness.readData(), harness.originalData) + } + } + + func testMixedLegacyAndNewHooksAndDuplicatePostHooksAreRejected() throws { + for hooks in [ + ["PreToolUse": [Self.legacyHookGroup, Self.expectedHookGroup]], + ["PreToolUse": [Self.legacyHookGroup], "PostToolUse": [Self.expectedHookGroup]], + ["PostToolUse": [Self.expectedHookGroup, Self.expectedHookGroup]] + ] { + let harness = try Harness(data: Self.jsonData(root: ["hooks": hooks])) + XCTAssertThrowsError(try harness.configuration.preview()) { error in + XCTAssertEqual(error as? CodexDiscoveryHookConfigurationError, .multipleExpectedHooks) + } + XCTAssertEqual(try harness.readData(), harness.originalData) + } + } } private extension CodexDiscoveryHookConfigurationTests { static let expectedHookGroup: [String: Any] = [ + "matcher": "^(Bash|mcp__askkey__list_credentials)$", + "hooks": [["type": "command", "command": "\"/Applications/Ask Key.app/Contents/Helpers/askkey\" hook codex", "timeout": 3]] + ] + + static let legacyHookGroup: [String: Any] = [ "matcher": "^(Bash|mcp__askkey__list_credentials)$", "hooks": [[ "type": "mcp_tool", @@ -438,6 +502,12 @@ private extension Dictionary where Key == String, Value == Any { } } +private extension Array where Element == [String: Any] { + func asJSONData() throws -> Data { + try JSONSerialization.data(withJSONObject: self, options: [.sortedKeys]) + } +} + private extension URL { func readData() throws -> Data { try Data(contentsOf: self) diff --git a/Tests/AskKeyIntegrationsTests/CodexNativeHookClientTests+Harness.swift b/Tests/AskKeyIntegrationsTests/CodexNativeHookClientTests+Harness.swift new file mode 100644 index 0000000..cfadea8 --- /dev/null +++ b/Tests/AskKeyIntegrationsTests/CodexNativeHookClientTests+Harness.swift @@ -0,0 +1,215 @@ +import Darwin +import Foundation +import XCTest +@testable import AskKeyIntegrations + +extension CodexNativeHookClientTests { + final class Harness { + enum HookState: Equatable { + case missing + case disabled + case untrusted + case enabled + case modified + } + + let root: URL + let codexDirectory: URL + let hooksURL: URL + let configURL: URL + let executable: URL + let client: CodexNativeHookClient + let hookKey: String + let postHookKey: String + let currentHash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + let postHash = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + let originalConfig = Data("model = \"fixture\"\n".utf8) + private let initialTrust: String + private let requestLog: URL + + init(hookState: HookState, surroundingGroups: Bool = false, onlyEvent: String? = nil, legacy: Bool = false, + metadataOverrides: [String: [String: Any]] = [:], concurrentHookEdit: Bool = false) throws { + initialTrust = hookState == .modified ? "modified" + : ([.disabled, .enabled].contains(hookState) ? "trusted" : "untrusted") + root = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("askkey-native-hook-\(UUID().uuidString)", isDirectory: true) + codexDirectory = root.appendingPathComponent(".codex", isDirectory: true) + hooksURL = codexDirectory.appendingPathComponent("hooks.json") + configURL = codexDirectory.appendingPathComponent("config.toml") + executable = root.appendingPathComponent("codex-fixture") + let index = surroundingGroups ? 1 : 0 + hookKey = "\(hooksURL.path):pre_tool_use:\(index):0" + postHookKey = "\(hooksURL.path):post_tool_use:\(index):0" + requestLog = root.appendingPathComponent("requests.jsonl") + client = CodexNativeHookClient(executable: executable, userHome: root) + + try FileManager.default.createDirectory(at: codexDirectory, withIntermediateDirectories: true) + try writeHooks(state: hookState, surroundingGroups: surroundingGroups, onlyEvent: onlyEvent, legacy: legacy) + try originalConfig.write(to: configURL) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: configURL.path) + try writeExecutable(enabled: hookState == .enabled, metadataOverrides: metadataOverrides, + concurrentHookEdit: concurrentHookEdit) + } + + deinit { + try? FileManager.default.removeItem(at: root) + } + + func methods() throws -> [String] { + try requests().compactMap { $0["method"] as? String } + } + + func arguments() throws -> [String] { + let data = try Data(contentsOf: root.appendingPathComponent("argv.json")) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String]) + } + + func requests() throws -> [[String: Any]] { + guard FileManager.default.fileExists(atPath: requestLog.path) else { return [] } + return try String(contentsOf: requestLog, encoding: .utf8) + .split(whereSeparator: \.isNewline) + .map { try XCTUnwrap(JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } + } + + private func writeHooks(state: HookState, surroundingGroups: Bool, onlyEvent: String?, legacy: Bool) throws { + let commandHook: [String: Any] = [ + "matcher": "^(Bash|mcp__askkey__list_credentials)$", + "hooks": [["type": "command", "command": "\"/Applications/Ask Key.app/Contents/Helpers/askkey\" hook codex", "timeout": 3]] + ] + let legacyHook: [String: Any] = [ + "matcher": "^(Bash|mcp__askkey__list_credentials)$", + "hooks": [[ + "type": "mcp_tool", + "server": "askkey", + "tool": "credential_discovery_guard", + "input": [ + "session_id": "${session_id}", + "turn_id": "${turn_id}", + "tool_name": "${tool_name}", + "tool_input": "${tool_input}" + ], + "timeout": 3 + ]] + ] + let unrelated: [String: Any] = ["matcher": "unrelated", "hooks": [["type": "command", "command": "true"]]] + var events: [String: Any] = [:] + for event in ["PreToolUse", "PostToolUse"] { + let present = state != .missing && (onlyEvent == nil || onlyEvent == event) && (!legacy || event == "PreToolUse") + let own = present ? [legacy ? legacyHook : commandHook] : [] + events[event] = surroundingGroups ? [unrelated] + own + [unrelated] : own + } + let root: [String: Any] = ["hooks": events] + let data = try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]) + try data.write(to: hooksURL) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: hooksURL.path) + } + + private func writeExecutable(enabled: Bool, metadataOverrides: [String: [String: Any]], concurrentHookEdit: Bool) throws { + let homeLiteral = String(reflecting: root.path) + let configLiteral = String(reflecting: configURL.path) + let hooksLiteral = String(reflecting: hooksURL.path) + let logLiteral = String(reflecting: requestLog.path) + let hashLiteral = String(reflecting: currentHash) + let postHashLiteral = String(reflecting: postHash) + let initialTrustLiteral = String(reflecting: initialTrust) + let legacyKeyLiteral = String(reflecting: hookKey) + let overridesData = try JSONSerialization.data(withJSONObject: metadataOverrides, options: [.sortedKeys]) + let overridesLiteral = String(reflecting: String(decoding: overridesData, as: UTF8.self)) + let script = """ + #!/usr/bin/python3 + import json + import pathlib + import sys + + home = \(homeLiteral) + config = \(configLiteral) + hooks = \(hooksLiteral) + log_path = pathlib.Path(\(logLiteral)) + current_hash = \(hashLiteral) + post_hash = \(postHashLiteral) + enabled = \(enabled ? "True" : "False") + trusted = \(initialTrustLiteral) + overrides = json.loads(\(overridesLiteral)) + concurrent_hook_edit = \(concurrentHookEdit ? "True" : "False") + trust_state = {"unrelated-disabled": {"enabled": False, "trusted_hash": "keep"}, + "legacy-mcp-key": {"enabled": False, "trusted_hash": "old"}} + if trusted == "modified": + trust_state[\(legacyKeyLiteral)] = {"enabled": False, + "trusted_hash": "sha256:de649513d3d2d2d50c5a9747079e3fa5879d89ea2e9fb15238e7923b0efa6c93"} + + pathlib.Path(sys.argv[0]).with_name("argv.json").write_text(json.dumps(sys.argv[1:])) + + def hook(event, event_key, index): + metadata = { + "key": hooks + ":" + event_key + ":" + str(index) + ":0", + "currentHash": current_hash if event == "preToolUse" else post_hash, + "enabled": enabled, + "eventName": event, + "isManaged": False, + "matcher": "^(Bash|mcp__askkey__list_credentials)$", + "source": "user", + "sourcePath": hooks, + "timeoutSec": 3, + "trustStatus": "untrusted" if trusted == "modified" and event == "postToolUse" else trusted, + "handlerType": "command", + "command": '\"/Applications/Ask Key.app/Contents/Helpers/askkey\" hook codex', + "async": False, + "displayOrder": 0, + } + metadata.update(overrides.get(event, {})) + return metadata + + def config_read(): + return { + "config": {}, + "origins": { + "hooks": { + "name": {"type": "user", "file": config}, + "version": "fixture-version-1", + } + }, + "layers": [{ + "name": {"type": "user", "file": config}, + "version": "fixture-version-1", + "config": {"hooks": {"state": trust_state}}, + }], + } + + for line in sys.stdin: + request = json.loads(line) + with log_path.open("a") as log: + log.write(json.dumps(request) + "\\n") + if "id" not in request: + continue + method = request.get("method") + if method == "initialize": + result = {"userAgent": "fixture", "codexHome": home + "/.codex"} + elif method == "hooks/list": + document = json.loads(pathlib.Path(hooks).read_text()) + metadata = [] + for raw, event, event_key in [("PreToolUse", "preToolUse", "pre_tool_use"), + ("PostToolUse", "postToolUse", "post_tool_use")]: + for index, group in enumerate(document.get("hooks", {}).get(raw, [])): + if group.get("hooks", [{}])[0].get("command", "").endswith(" hook codex"): + metadata.append(hook(event, event_key, index)) + result = {"data": [{"cwd": home, "errors": [], "hooks": metadata, "warnings": []}]} + elif method == "config/read": + if concurrent_hook_edit: + document = json.loads(pathlib.Path(hooks).read_text()) + document["keep"] = "concurrent" + pathlib.Path(hooks).write_text(json.dumps(document)) + result = config_read() + elif method == "config/batchWrite": + enabled = True + trusted = "trusted" + trust_state.update(request["params"]["edits"][0]["value"]) + result = {"status": "ok"} + else: + result = {} + print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True) + """ + try Data(script.utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + } + } +} diff --git a/Tests/AskKeyIntegrationsTests/CodexNativeHookClientTests.swift b/Tests/AskKeyIntegrationsTests/CodexNativeHookClientTests.swift index 749c34a..3108402 100644 --- a/Tests/AskKeyIntegrationsTests/CodexNativeHookClientTests.swift +++ b/Tests/AskKeyIntegrationsTests/CodexNativeHookClientTests.swift @@ -25,6 +25,79 @@ final class CodexNativeHookClientTests: XCTestCase { XCTAssertEqual(try harness.client.status(), .missing) } + func testOnlyLegacyOrOneCommandEventRequiresReconnectWithoutTrustWrite() throws { + for event in ["PreToolUse", "PostToolUse"] { + let harness = try Harness(hookState: .disabled, onlyEvent: event) + XCTAssertEqual(try harness.client.status(), .missing) + XCTAssertThrowsError(try harness.client.enableReviewedHook()) + XCTAssertFalse(try harness.methods().contains("config/batchWrite")) + } + let legacy = try Harness(hookState: .disabled, legacy: true) + XCTAssertEqual(try legacy.client.status(), .missing) + XCTAssertFalse(try legacy.methods().contains("config/batchWrite")) + } + + func testTrustUsesActualKeysWithGroupsBeforeAndAfterOurHandlers() throws { + let harness = try Harness(hookState: .disabled, surroundingGroups: true) + XCTAssertEqual(try harness.client.enableReviewedHook(), .enabled) + let write = try XCTUnwrap(try harness.requests().first { $0["method"] as? String == "config/batchWrite" }) + let params = try XCTUnwrap(write["params"] as? [String: Any]) + let edits = try XCTUnwrap(params["edits"] as? [[String: Any]]) + let value = try XCTUnwrap(edits.first?["value"] as? [String: Any]) + XCTAssertEqual(Set(value.keys), [harness.hookKey, harness.postHookKey]) + XCTAssertTrue(harness.hookKey.hasSuffix(":pre_tool_use:1:0")) + XCTAssertTrue(harness.postHookKey.hasSuffix(":post_tool_use:1:0")) + XCTAssertNil(value["unrelated-disabled"]) + XCTAssertNil(value["legacy-mcp-key"]) + } + + func testInPlaceLegacyKeyUsesNewCurrentHashAndModifiedStatusNeedsTrust() throws { + let harness = try Harness(hookState: .modified) + XCTAssertEqual(try harness.client.status(), .untrusted) + XCTAssertEqual(try harness.client.enableReviewedHook(), .enabled) + let write = try XCTUnwrap(try harness.requests().first { $0["method"] as? String == "config/batchWrite" }) + let params = try XCTUnwrap(write["params"] as? [String: Any]) + let edits = try XCTUnwrap(params["edits"] as? [[String: Any]]) + let value = try XCTUnwrap(edits.first?["value"] as? [String: [String: Any]]) + XCTAssertEqual(value[harness.hookKey]?["trusted_hash"] as? String, harness.currentHash) + XCTAssertNotEqual(value[harness.hookKey]?["trusted_hash"] as? String, + "sha256:de649513d3d2d2d50c5a9747079e3fa5879d89ea2e9fb15238e7923b0efa6c93") + XCTAssertEqual(Set(value.keys), [harness.hookKey, harness.postHookKey]) + XCTAssertNil(value["legacy-mcp-key"], "Stale entries outside current keys remain untouched") + } + + func testBothHandlersMustBeEnabledAndTrustedForReadiness() throws { + let disabledPost = try Harness(hookState: .enabled, + metadataOverrides: ["postToolUse": ["enabled": false]]) + XCTAssertEqual(try disabledPost.client.status(), .disabled) + let untrustedPost = try Harness(hookState: .enabled, + metadataOverrides: ["postToolUse": ["trustStatus": "untrusted"]]) + XCTAssertEqual(try untrustedPost.client.status(), .untrusted) + let enabled = try Harness(hookState: .enabled) + XCTAssertEqual(try enabled.client.enableReviewedHook(), .enabled) + XCTAssertFalse(try enabled.methods().contains("config/batchWrite")) + } + + func testMismatchedCommandMetadataCannotReceiveTrust() throws { + for override in [["handlerType": "mcpTool"], ["timeoutSec": 4], ["async": true], + ["matcher": "custom"], ["currentHash": "invalid"], ["source": "project"], + ["key": "unrelated-disabled"]] as [[String: Any]] { + let harness = try Harness(hookState: .disabled, metadataOverrides: ["postToolUse": override]) + XCTAssertThrowsError(try harness.client.enableReviewedHook()) + XCTAssertFalse(try harness.methods().contains("config/batchWrite")) + XCTAssertEqual(try Data(contentsOf: harness.configURL), harness.originalConfig) + } + } + + func testConcurrentHookEditStopsTrustAndPreservesTheExternalEdit() throws { + let harness = try Harness(hookState: .disabled, concurrentHookEdit: true) + XCTAssertThrowsError(try harness.client.enableReviewedHook()) + XCTAssertFalse(try harness.methods().contains("config/batchWrite")) + XCTAssertEqual(try Data(contentsOf: harness.configURL), harness.originalConfig) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: harness.hooksURL)) as? [String: Any]) + XCTAssertEqual(root["keep"] as? String, "concurrent") + } + func testPublicClientRejectsAUserHomeDifferentFromTheCurrentHome() throws { let harness = try Harness(hookState: .disabled) let otherHome = harness.root.appendingPathComponent("other-home", isDirectory: true) @@ -42,7 +115,7 @@ final class CodexNativeHookClientTests: XCTestCase { } } - func testEnableReviewedHookWritesOnlyOneTrustStateAndReadsItBack() throws { + func testEnableReviewedHookWritesOnlyTwoCurrentTrustStatesAndReadsThemBack() throws { let harness = try Harness(hookState: .disabled) XCTAssertEqual(try harness.client.enableReviewedHook(), .enabled) @@ -63,10 +136,13 @@ final class CodexNativeHookClientTests: XCTestCase { XCTAssertEqual(edits[0]["keyPath"] as? String, "hooks.state") XCTAssertEqual(edits[0]["mergeStrategy"] as? String, "upsert") let value = try XCTUnwrap(edits[0]["value"] as? [String: Any]) - XCTAssertEqual(value.keys.sorted(), [harness.hookKey]) + XCTAssertEqual(value.keys.sorted(), [harness.hookKey, harness.postHookKey].sorted()) let state = try XCTUnwrap(value[harness.hookKey] as? [String: Any]) XCTAssertEqual(state["enabled"] as? Bool, true) XCTAssertEqual(state["trusted_hash"] as? String, harness.currentHash) + let postState = try XCTUnwrap(value[harness.postHookKey] as? [String: Any]) + XCTAssertEqual(postState["enabled"] as? Bool, true) + XCTAssertEqual(postState["trusted_hash"] as? String, harness.postHash) let backupDirectory = harness.root .appendingPathComponent("Library/Application Support/AskKey/client-backups/codex-trust", isDirectory: true) @@ -330,178 +406,3 @@ private extension CodexNativeHookClientTests { XCTAssertEqual(errno, ESRCH) } } - -private extension CodexNativeHookClientTests { - final class Harness { - enum HookState: Equatable { - case missing - case disabled - case untrusted - } - - let root: URL - let codexDirectory: URL - let hooksURL: URL - let configURL: URL - let executable: URL - let client: CodexNativeHookClient - let hookKey: String - let currentHash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - let originalConfig = Data("model = \"fixture\"\n".utf8) - private let hookPresent: Bool - private let initialTrust: String - private let requestLog: URL - - init(hookState: HookState) throws { - hookPresent = hookState != .missing - initialTrust = hookState == .disabled ? "trusted" : "untrusted" - root = URL(fileURLWithPath: "/tmp", isDirectory: true) - .appendingPathComponent("askkey-native-hook-\(UUID().uuidString)", isDirectory: true) - codexDirectory = root.appendingPathComponent(".codex", isDirectory: true) - hooksURL = codexDirectory.appendingPathComponent("hooks.json") - configURL = codexDirectory.appendingPathComponent("config.toml") - executable = root.appendingPathComponent("codex-fixture") - hookKey = "\(hooksURL.path):pre_tool_use:0:0" - requestLog = root.appendingPathComponent("requests.jsonl") - client = CodexNativeHookClient(executable: executable, userHome: root) - - try FileManager.default.createDirectory(at: codexDirectory, withIntermediateDirectories: true) - try writeHooks(state: hookState) - try originalConfig.write(to: configURL) - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: configURL.path) - try writeExecutable() - } - - deinit { - try? FileManager.default.removeItem(at: root) - } - - func methods() throws -> [String] { - try requests().compactMap { $0["method"] as? String } - } - - func arguments() throws -> [String] { - let data = try Data(contentsOf: root.appendingPathComponent("argv.json")) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String]) - } - - func requests() throws -> [[String: Any]] { - guard FileManager.default.fileExists(atPath: requestLog.path) else { return [] } - return try String(contentsOf: requestLog, encoding: .utf8) - .split(whereSeparator: \.isNewline) - .map { try XCTUnwrap(JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } - } - - private func writeHooks(state: HookState) throws { - let hook: [String: Any] = [ - "matcher": "^(Bash|mcp__askkey__list_credentials)$", - "hooks": [[ - "type": "mcp_tool", - "server": "askkey", - "tool": "credential_discovery_guard", - "input": [ - "session_id": "${session_id}", - "turn_id": "${turn_id}", - "tool_name": "${tool_name}", - "tool_input": "${tool_input}" - ], - "timeout": 3 - ]] - ] - let root: [String: Any] = [ - "hooks": [ - "PreToolUse": state == .missing ? [] : [hook] - ] - ] - let data = try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]) - try data.write(to: hooksURL) - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: hooksURL.path) - } - - private func writeExecutable() throws { - let homeLiteral = String(reflecting: root.path) - let configLiteral = String(reflecting: configURL.path) - let hooksLiteral = String(reflecting: hooksURL.path) - let logLiteral = String(reflecting: requestLog.path) - let keyLiteral = String(reflecting: hookKey) - let hashLiteral = String(reflecting: currentHash) - let hookPresentLiteral = hookPresent ? "True" : "False" - let initialTrustLiteral = String(reflecting: initialTrust) - let script = """ - #!/usr/bin/python3 - import json - import pathlib - import sys - - home = \(homeLiteral) - config = \(configLiteral) - hooks = \(hooksLiteral) - log_path = pathlib.Path(\(logLiteral)) - hook_key = \(keyLiteral) - current_hash = \(hashLiteral) - hook_present = \(hookPresentLiteral) - enabled = False - trusted = \(initialTrustLiteral) - - pathlib.Path(sys.argv[0]).with_name("argv.json").write_text(json.dumps(sys.argv[1:])) - - def hook(): - return { - "key": hook_key, - "currentHash": current_hash, - "enabled": enabled, - "eventName": "preToolUse", - "isManaged": False, - "matcher": "^(Bash|mcp__askkey__list_credentials)$", - "source": "user", - "sourcePath": hooks, - "timeoutSec": 3, - "trustStatus": trusted, - "handlerType": "mcpTool", - "server": "askkey", - "tool": "credential_discovery_guard", - "displayOrder": 0, - } - - def config_read(): - return { - "config": {}, - "origins": { - "hooks": { - "name": {"type": "user", "file": config}, - "version": "fixture-version-1", - } - }, - "layers": [{ - "name": {"type": "user", "file": config}, - "version": "fixture-version-1", - "config": {}, - }], - } - - for line in sys.stdin: - request = json.loads(line) - with log_path.open("a") as log: - log.write(json.dumps(request) + "\\n") - if "id" not in request: - continue - method = request.get("method") - if method == "initialize": - result = {"userAgent": "fixture", "codexHome": home + "/.codex"} - elif method == "hooks/list": - result = {"data": [{"cwd": home, "errors": [], "hooks": [hook()] if hook_present else [], "warnings": []}]} - elif method == "config/read": - result = config_read() - elif method == "config/batchWrite": - enabled = True - trusted = "trusted" - result = {"status": "ok"} - else: - result = {} - print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True) - """ - try Data(script.utf8).write(to: executable) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) - } - } -} diff --git a/docs/client-integrations.md b/docs/client-integrations.md index 2324ad7..c0c6205 100644 --- a/docs/client-integrations.md +++ b/docs/client-integrations.md @@ -19,7 +19,7 @@ The paths in this table are user-runtime configuration files, not tracked reposi | Client | MCP configuration | Discovery setup | | --- | --- | --- | | Claude Code | Configure the user-scope stdio server via official CLI: `claude mcp add-json askkey '{"type":"stdio","command":"/Applications/Ask Key.app/Contents/Helpers/askkey","args":["mcp"]}' --scope user` (stored in `~/.claude.json`). Direct edits to `~/.claude.json` are avoided. | Merge AskKey-owned command Hook handlers into `~/.claude/settings.json` for `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` (with matcher `Bash\|mcp__askkey__list_credentials`), preserving unrelated handlers, matcher groups, and their order. It invokes the installed helper with `hook claude`. | -| Codex | `~/.codex/config.toml`, AskKey's `[mcp_servers.askkey]` entry. The existing TOML transaction preserves unrelated content and verifies readback. | Add the standard AskKey `PreToolUse` MCP-tool Hook to `~/.codex/hooks.json`. Through native app-server `config/batchWrite`, enable and trust only that Hook's current hash in the user configuration. | +| Codex | `~/.codex/config.toml`, AskKey's `[mcp_servers.askkey]` entry. The existing TOML transaction preserves unrelated content and verifies readback. | Install the standard AskKey `PreToolUse` and `PostToolUse` command Hooks in `~/.codex/hooks.json`, invoking `"/Applications/Ask Key.app/Contents/Helpers/askkey" hook codex` with matcher `^(Bash\|mcp__askkey__list_credentials)$` and a three-second timeout. An exact legacy MCP-tool group is replaced in place; customized or duplicate definitions stop setup. Explicit Connect uses native app-server `config/batchWrite` to enable and trust only both new current hashes, preserving state outside those current keys. A reused legacy key receives the new hash after explicit review (`modified` means it needs trust); other stale legacy entries remain untouched. | | Cursor | Merge `mcpServers.askkey` into `~/.cursor/mcp.json`, preserving other servers and existing file permissions. | Merge the standard command Hook into `~/.cursor/hooks.json` for `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserving other handlers and their order. It invokes the installed helper with `hook cursor`. | | Grok CLI | Update `[mcp_servers.askkey]` in `~/.grok/config.toml` with the lossless TOML writer, preserving unrelated content. | Create or verify the owned file `~/.grok/hooks/askkey-discovery.json` for `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`. It invokes the installed helper with `hook grok`; unknown customized content is not overwritten. | @@ -38,7 +38,7 @@ All four adapters read back the expected server configuration, verify the helper | Client | Additional checks and source | | --- | --- | | Claude Code | Require official `claude mcp get askkey` to report the expected user-scope stdio server with connected status (`✔ Connected`). Verify helper signature trust, MCP identity/tools (`list_credentials`, `run`), and helper `health` including Broker version. Separately read back the owned Hook configuration from `~/.claude/settings.json`. See [Claude MCP adapter](../Sources/AskKeyIntegrations/ClaudeCodeMCPAdapter.swift) and [command discovery verification](../Sources/AskKeyIntegrations/CommandDiscoveryIntegration.swift). | -| Codex | Verify enabled MCP configuration, executable non-symlink helper, Broker health and Broker protocol version. Onboarding's MCP verification additionally requires `credential_discovery_guard`. Native `hooks/list` must report the exact standard user Hook enabled and trusted, with the expected source, matcher, handler, and current hash. See [MCP verification](../Sources/AskKeyIntegrations/CodexUserMCPAdapter+Verification.swift) and [native Hook verification](../Sources/AskKeyIntegrations/CodexNativeHookClient.swift). | +| Codex | Verify enabled MCP configuration, executable non-symlink helper, Broker health and Broker protocol version. Onboarding probes `hook capabilities` for Codex command support; MCP verification requires `list_credentials` and `run`, with the legacy `credential_discovery_guard` retained for unmigrated clients. Native `hooks/list` must report both exact standard user command Hooks enabled and trusted, with the expected events, source, matcher, synchronous handlers, timeout, and current hashes, regardless of their positions. A legacy-only or partial command definition needs reconnection. See [MCP verification](../Sources/AskKeyIntegrations/CodexUserMCPAdapter+Verification.swift) and [native Hook verification](../Sources/AskKeyIntegrations/CodexNativeHookClient.swift). | | Cursor | Verify configuration, executable trusted helper, MCP handshake/tool list, and Broker health. Separately probe `hook capabilities` for Cursor support and read back the standard Hook configuration. See [MCP verification](../Sources/AskKeyIntegrations/CursorUserMCPAdapter+Verification.swift), [MCP process probe](../Sources/AskKeyIntegrations/CursorUserMCPAdapter+Process.swift), and [command discovery verification](../Sources/AskKeyIntegrations/CommandDiscoveryIntegration.swift). | | Grok CLI | Require official `mcp list --json` to report the expected non-project stdio server, and `mcp doctor --json askkey` to report it healthy. Verify helper trust, MCP identity/tools, and helper `health` including Broker version. Separately probe Grok command-Hook capability and read back the owned Hook definition. See [Grok verification](../Sources/AskKeyIntegrations/GrokCLIAdapter+Verification.swift). | @@ -56,7 +56,7 @@ Cancellation is scoped to the active connection operation through [RestrictedPro ## Discovery and credential use -Discovery Hooks guide the Agent to query the credential catalog before a direct SSH connection. They never grant credential access. Codex's native Hook has a three-second timeout and can allow the client to continue when unavailable. Cursor, Grok, and Claude Code command Hooks stop blocking after 30 seconds without discovery progress; a missing callback is not recorded as successful discovery. +Discovery Hooks guide the Agent to query the credential catalog before a direct SSH connection. They never grant credential access. All four command Hooks stop blocking after 30 seconds without discovery progress; a failed or missing callback is not recorded as successful discovery. Codex's native command execution also has a three-second timeout and can allow the client to continue when unavailable. Codex 0.160.0 keys state by `session_id` and `turn_id`, treats catalog `PreToolUse` as progress, and settles only its matching `PostToolUse`; it has no `PostToolUseFailure` event, so failures use the no-progress release. The contract follows [ADR 0009](adr/0009-codex-command-discovery-hook.md); repeated Orca-session trust is accepted separately with the installed release in #127. [CommandDiscoveryHook](../Sources/AskKeyHelper/CommandDiscoveryHook.swift) and [DiscoveryTurnStore](../Sources/AskKeyHelper/DiscoveryTurnStore.swift) implement the command-Hook flow. The runtime state retains hashes, timestamps, and completion markers with bounded retention, rather than command text, hostnames, or credential values. Cursor's generic completion name `MCP:list_credentials` has no server identity; a same-named tool can settle its reminder. This state is guidance, not authorization evidence. Grok uses `askkey__list_credentials` and binds events to its session and turn information. Claude Code uses `UserPromptSubmit` to begin a turn for `session_id`, matches `Bash` commands for SSH connections and `mcp__askkey__list_credentials` to settle the reminder in `PostToolUse` or `PostToolUseFailure`, returning the standard permission-denial envelope when a reminder is active.