Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
5a4632d
test: cover Copilot PreToolUse feed gating
austinywang Jun 22, 2026
899cca3
fix: gate Copilot PreToolUse feed hooks
austinywang Jun 22, 2026
af19510
fix: address Copilot hook review feedback
austinywang Jun 22, 2026
f280731
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
2e41944
fix: write Copilot hooks with direct schema
austinywang Jun 22, 2026
b759c63
fix: address Copilot decision review feedback
austinywang Jun 22, 2026
07c39ad
fix: clean up Copilot hook version logic
austinywang Jun 22, 2026
b28221d
fix: add Copilot hook timeout slack
austinywang Jun 22, 2026
7f67268
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
bba9f38
fix: keep Copilot PreToolUse on permission schema
austinywang Jun 22, 2026
2ec1d7b
fix: restrict Copilot feed permission modes
austinywang Jun 22, 2026
bd85d15
test: convert Copilot hook feed coverage to Swift Testing
austinywang Jun 22, 2026
2ce9925
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
95fc809
test: address Copilot classification review notes
austinywang Jun 22, 2026
ee92c83
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
0fc56e3
fix: avoid blocking Copilot read-only tools
austinywang Jun 22, 2026
9f79ced
fix: avoid Copilot notification stop hooks
austinywang Jun 22, 2026
3c97c5c
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
35db201
docs: fix Copilot CLI hooks link
austinywang Jun 22, 2026
837643a
Merge branch 'main' into issue-6574-copilot-pretooluse-hook
austinywang Jun 22, 2026
8c2a8a8
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
3bb1e4d
fix: use canonical Copilot hook schema
austinywang Jun 22, 2026
d50ee57
chore: refresh Swift file length budget
austinywang Jun 22, 2026
e302406
fix: gate Copilot ask_user hooks
austinywang Jun 22, 2026
8713026
fix: route Copilot errors as notifications
austinywang Jun 22, 2026
3fdc675
docs: update Copilot error hook payload sample
austinywang Jun 22, 2026
52da06f
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
5c77b13
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
5afbfee
fix: add Copilot permissionRequest feed hook
austinywang Jun 22, 2026
70ec159
fix: avoid duplicate Copilot feed approvals
austinywang Jun 22, 2026
88bb7e5
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
5c9950f
fix: preserve Copilot preToolUse wire name
austinywang Jun 22, 2026
477406f
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
a27730c
fix: make Copilot preToolUse gate actionable
austinywang Jun 22, 2026
64340ca
fix: install Copilot permissionRequest gate
austinywang Jun 22, 2026
5c3f7c1
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
ff85293
fix: tighten Copilot telemetry hooks
austinywang Jun 22, 2026
7d5c019
fix: remove Copilot preToolUse telemetry hook
austinywang Jun 22, 2026
18ef6f1
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
f4086b0
fix: preserve Copilot permission policies
austinywang Jun 22, 2026
5c30136
fix: approve Copilot permission requests
austinywang Jun 22, 2026
8acf422
chore: refresh Swift file length budget
austinywang Jun 22, 2026
db9f616
fix: preserve Copilot denial authority
austinywang Jun 22, 2026
a5ef394
fix: gate Copilot after native permissions
austinywang Jun 22, 2026
4e89e4c
fix: fail closed for Copilot hook wrapper
austinywang Jun 22, 2026
1397752
merge: sync with main
austinywang Jun 22, 2026
1c12804
fix: deny Copilot when Feed socket is unavailable
austinywang Jun 22, 2026
ba1eec7
merge: sync with main
austinywang Jun 22, 2026
663f1b6
test: stabilize SSH CLI output capture
austinywang Jun 22, 2026
fb3e64b
fix: scope Copilot preToolUse hook matcher
austinywang Jun 22, 2026
4bac51b
Merge remote-tracking branch 'origin/main' into issue-6574-copilot-pr…
austinywang Jun 22, 2026
b612558
test: migrate touched legacy suites to Swift Testing
austinywang Jun 22, 2026
9ef971e
test: avoid blocking main actor in migrated waits
austinywang Jun 22, 2026
9097e2c
test: finish Swift Testing migration for hook suite extensions
austinywang Jun 22, 2026
cb1ea2d
test: avoid suite captures in migrated hook assertions
austinywang Jun 22, 2026
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
17 changes: 9 additions & 8 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34289 CLI/cmux.swift
34397 CLI/cmux.swift
17830 Sources/AppDelegate.swift
16117 Sources/ContentView.swift
13952 Sources/TerminalController.swift
12783 Sources/Workspace.swift
12240 Sources/GhosttyTerminalView.swift
12144 cmuxTests/AppDelegateShortcutRoutingTests.swift
11929 Sources/Panels/BrowserPanel.swift
9335 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
9480 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
8017 CLI/cmux_open.swift
7986 Sources/Panels/BrowserPanelView.swift
7490 cmuxTests/WorkspaceRemoteConnectionTests.swift
7354 cmuxTests/WorkspaceUnitTests.swift
7218 cmuxTests/WorkspaceRemoteConnectionTests.swift
6317 cmuxTests/SessionPersistenceTests.swift
6217 cmuxTests/GhosttyConfigTests.swift
6178 Sources/TabManager.swift
Expand All @@ -29,7 +29,7 @@
3926 cmuxTests/TabManagerUnitTests.swift
3896 cmuxTests/WindowAndDragTests.swift
3734 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3699 cmuxTests/CLIGenericHookPersistenceTests.swift
3706 cmuxTests/CLIGenericHookPersistenceTests.swift
3397 Sources/CmuxConfig.swift
3364 cmuxTests/TabManagerSessionSnapshotTests.swift
3058 Sources/Update/UpdateTitlebarAccessory.swift
Expand Down Expand Up @@ -106,12 +106,13 @@
865 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
847 cmuxTests/SSHStartupSignalLifecycleTests.swift
830 Sources/TaskManagerTypes.swift
825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
824 Sources/MainWindowFocusController.swift
810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift
802 Sources/WorkspaceContentView.swift
801 cmuxTests/CLINotifyProcessTestSupport.swift
797 Sources/ClosedItemHistory.swift
779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift
769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
Expand All @@ -123,13 +124,13 @@
754 Sources/TerminalController+ControlWorkspaceContext.swift
752 cmuxUITests/CloseWorkspaceCmdDUITests.swift
738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift
735 CLI/CMUXCLI+AgentHookDefinitions.swift
726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift
722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift
718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
716 Sources/TaskManagerSnapshot.swift
715 Sources/AppleScriptSupport.swift
710 Sources/TerminalSSHSessionDetector.swift
707 CLI/CMUXCLI+AgentHookDefinitions.swift
706 CLI/CMUXCLI+Config.swift
705 Sources/Panels/BrowserPopupWindowController.swift
699 cmuxTests/TerminalNotificationClearAllTests.swift
Expand All @@ -140,11 +141,12 @@
688 cmuxTests/KeyboardShortcutContextTests.swift
683 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift
683 Sources/Panels/CodexAppServerSession.swift
682 cmuxTests/CLICopilotHookFeedTests.swift
681 Sources/Panels/AgentSessionProcessStore.swift
680 Sources/FileExplorerSearchController.swift
677 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Bootstrap.swift
668 cmuxTests/FeedCoordinatorTests.swift
668 cmuxTests/SettingsWindowPresenterTests.swift
666 cmuxTests/FeedCoordinatorTests.swift
664 Sources/CmuxTopSnapshot.swift
663 Sources/PortScanner.swift
663 cmuxTests/SessionIndexViewTests.swift
Expand Down Expand Up @@ -200,7 +202,6 @@
531 Sources/App/WorkspaceRuntimeSettings.swift
530 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift
529 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift
528 cmuxTests/CLINotifyProcessTestSupport.swift
528 cmuxUITests/AutomationSocketUITests.swift
527 CLI/CLISocketPathResolver.swift
526 Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift
Expand Down
48 changes: 38 additions & 10 deletions CLI/CMUXCLI+AgentHookDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ extension CMUXCLI {

enum HookFormat {
case flat // Cursor: {"hooks": {"event": [{"command": "..."}]}, "version": 1}
case nested(timeoutMs: Int) // Nested type/command/timeout hooks; timeout unit is agent-specific.
case nested(timeoutMs: Int), copilotJSON(timeoutSeconds: Int) // Agent-specific command hook JSON.
case kiroAgentJSON(timeoutMs: Int) // ~/.kiro/agents/*.json flat command entries with timeout_ms
case antigravityJSON(timeoutSeconds: Int) // ~/.gemini/config/hooks.json named hook groups
case rovoDevYAML
Expand Down Expand Up @@ -311,16 +311,18 @@ extension CMUXCLI {
),
AgentHookDef(
name: "copilot", displayName: "Copilot", statusKey: "copilot",
configDir: ".copilot", configFile: "config.json", configDirEnvOverride: "COPILOT_HOME",
configDir: ".copilot/hooks", configFile: "cmux.json", configDirEnvOverride: "COPILOT_HOME", configDirEnvOverrideSubpath: "hooks", createConfigDirIfMissing: true,
sessionStoreSuffix: "copilot", disableEnvVar: "CMUX_COPILOT_HOOKS_DISABLED",
hookMarker: "cmux hooks copilot", format: .nested(timeoutMs: 5000),
hookMarker: "cmux hooks copilot", format: .copilotJSON(timeoutSeconds: 5),
events: [
.init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"),
.init(agentEvent: "Stop", cmuxSubcommand: "stop"),
.init(agentEvent: "Notification", cmuxSubcommand: "stop"),
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
.init(agentEvent: "sessionStart", cmuxSubcommand: "session-start"),
.init(agentEvent: "userPromptSubmitted", cmuxSubcommand: "prompt-submit"),
.init(agentEvent: "agentStop", cmuxSubcommand: "stop"),
.init(agentEvent: "errorOccurred", cmuxSubcommand: "notification"),
.init(agentEvent: "notification", cmuxSubcommand: "notification"),
.init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"),
],
feedHookEvents: ["PreToolUse"]
feedHookEvents: ["preToolUse"]
),
AgentHookDef(
name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy",
Expand Down Expand Up @@ -380,14 +382,18 @@ extension CMUXCLI {
}

static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String {
let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)"
if def.name == "copilot", agentEvent == "preToolUse" {
return copilotPreToolUseAgentHookShellCommand(command, for: def)
}
switch def.format {
case .kiroAgentJSON:
return exitTwoPropagatingAgentHookShellCommand(
"cmux hooks feed --source \(def.name) --event \(agentEvent)",
command,
for: def
)
default:
return agentHookShellCommand("cmux hooks feed --source \(def.name) --event \(agentEvent)", for: def)
return agentHookShellCommand(command, for: def)
}
}

Expand All @@ -400,6 +406,28 @@ extension CMUXCLI {
return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then { if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; } || echo '{}'; else echo '{}'; fi"
}

private static func copilotPreToolUseAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String {
let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command
let denyOutput = shellSingleQuote(copilotPreToolUseDenyHookOutput())
return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -z \"$CMUX_SURFACE_ID\" ] || [ \"$\(def.disableEnvVar)\" = \"1\" ]; then echo '{}'; elif [ -z \"$cmux_cli\" ]; then echo \(denyOutput); else { if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; } || echo \(denyOutput); fi"
}

private static func copilotPreToolUseDenyHookOutput() -> String {
let reason = String(
localized: "cli.hooks.feed.permissionDeniedReason",
defaultValue: "User denied permission via cmux Feed."
)
let payload = [
"permissionDecision": "deny",
"permissionDecisionReason": reason,
]
guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]),
let output = String(data: data, encoding: .utf8) else {
return #"{"permissionDecision":"deny","permissionDecisionReason":"User denied permission via cmux Feed."}"#
}
return output
}

private static func exitTwoPropagatingAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String {
let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command
return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; status=$?; if [ \"$status\" -eq 2 ]; then exit 2; fi; if [ \"$status\" -ne 0 ]; then echo '{}'; fi; else echo '{}'; fi"
Expand Down
4 changes: 2 additions & 2 deletions CLI/CodexTeamsApprovalBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ enum CodexTeamsApprovalBridge {
}

static func feedSourceSupportsPersistentPermissionModes(_ source: String) -> Bool {
source != "hermes-agent"
source != "copilot" && source != "hermes-agent"
}

static func feedSourceSupportsOncePermissionMode(_ source: String, toolInputJSON: String?) -> Bool {
Expand All @@ -218,7 +218,7 @@ enum CodexTeamsApprovalBridge {
}

static func feedSourceSupportsBypassPermissions(_ source: String) -> Bool {
source != "codex" && source != "claude" && source != "hermes-agent"
source != "codex" && source != "claude" && source != "copilot" && source != "hermes-agent"
}

static func requestIdString(_ requestId: Any) -> String {
Expand Down
51 changes: 47 additions & 4 deletions CLI/FeedEventClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ struct FeedEventClassifier {
private enum FeedEventSemantic {
/// A real approval is pending; the user must approve/deny. Drives
/// the blocking Feed wait and the "needs approval" notification.
/// Resolved against the tool name so Claude's `ExitPlanMode` /
/// `AskUserQuestion` approvals route to their dedicated kinds.
/// Resolved against the tool name for agents whose response schema
/// supports dedicated `ExitPlanMode` / `AskUserQuestion` decisions.
case approvalRequest
/// A tool is about to run but no approval is pending. Telemetry
/// only. Used by agents that expose a *separate* approval event
Expand Down Expand Up @@ -109,8 +109,24 @@ struct FeedEventClassifier {
) -> (String, Bool) {
switch semantic {
case .approvalRequest:
// Copilot's hook response schema only understands top-level
// permissionDecision values, so keep every Copilot tool on the
// generic PermissionRequest wire kind.
if source == "copilot" {
return ("PermissionRequest", true)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return dedicatedApprovalEvent(for: toolName) ?? ("PermissionRequest", true)
case .toolStartMaybeApproval:
if source == "copilot" {
// Copilot's preToolUse fires for every tool. Gate only tools
// that need a decision, but keep Copilot on PermissionRequest
// so renderAgentDecision emits top-level permissionDecision.
if toolName == "ExitPlanMode" || toolName == "AskUserQuestion" || toolName == "ask_user" ||
Self.isSideEffectingTool(toolName, source: source) {
return ("PermissionRequest", true)
}
return ("PreToolUse", false)
}
if let dedicated = dedicatedApprovalEvent(for: toolName) {
return dedicated
}
Expand Down Expand Up @@ -216,6 +232,15 @@ struct FeedEventClassifier {
"agentSpawn": .sessionStart,
"stop": .response,
],
"copilot": [
// Copilot's canonical hook config uses camelCase event names. Keep
// the PascalCase spelling for already-installed VS Code compatible
// hook files.
"permissionRequest": .approvalRequest,
"PermissionRequest": .approvalRequest,
"preToolUse": .toolStartMaybeApproval,
"PreToolUse": .toolStartMaybeApproval,
],
]

/// Fallback table for agents without a dedicated entry in
Expand Down Expand Up @@ -291,16 +316,34 @@ struct FeedEventClassifier {
"generate_image",
]

/// Copilot's canonical hook payloads use lower-case runtime tool names.
/// Keep these source-scoped so another agent's lower-case read telemetry
/// is not accidentally broadened into approval traffic.
private static let copilotSideEffectingToolAliases: Set<String> = [
"bash",
"powershell",
"create",
"edit",
"str_replace_editor",
"apply_patch",
]

static let copilotPreToolUseApprovalToolMatcher =
copilotSideEffectingToolAliases.union(["ask_user"]).sorted().joined(separator: "|")

/// Whether a tool mutates state and deserves an approval prompt. Exact
/// match against ``sideEffectingTools`` for every source; the `kiro`
/// source additionally matches its case-insensitive internal aliases.
/// match against ``sideEffectingTools`` for every source; `copilot` and
/// `kiro` additionally match their case-insensitive internal aliases.
/// Kept source-scoped so another agent's lowercase tool name is not
/// escalated into an approval.
static func isSideEffectingTool(_ toolName: String, source: String) -> Bool {
guard !toolName.isEmpty else { return false }
if sideEffectingTools.contains(toolName) {
return true
}
if source == "copilot" {
return copilotSideEffectingToolAliases.contains(toolName.lowercased())
}
if source == "kiro" {
return kiroSideEffectingToolAliases.contains(toolName.lowercased())
}
Expand Down
Loading
Loading