Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/AskKeyAppKit/CodexOnboardingSetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 17 additions & 4 deletions Sources/AskKeyHelper/CommandDiscoveryHook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:<raw tool name>, 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,
Expand All @@ -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]]
}
Expand Down
9 changes: 7 additions & 2 deletions Sources/AskKeyHelper/DiscoveryTurnStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions Sources/AskKeyHelper/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [[
Expand Down Expand Up @@ -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
Expand All @@ -90,24 +95,38 @@ 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))
}
}
}
return matches
}

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 {
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ extension CodexDiscoveryHookConfiguration {
struct HookGroupMatch {
let eventName: String
let group: [String: Any]
let index: Int
let legacy: Bool
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading