Add external URL bypass rules for embedded browser opens (#768)

* Add external URL bypass rules for embedded browser opens

* Align open-wrapper external regex handling with app-side matcher
This commit is contained in:
Lawrence Chen 2026-03-02 17:50:34 -08:00 committed by GitHub
parent a6f6485e3c
commit fdc38a3326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 283 additions and 1 deletions

View file

@ -2390,6 +2390,17 @@ struct CMUXCLI {
let (workspaceOpt, argsAfterWorkspace) = parseOption(subArgs, name: "--workspace")
let (windowOpt, urlArgs) = parseOption(argsAfterWorkspace, name: "--window")
let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
let respectExternalOpenRules: Bool = {
guard let raw = ProcessInfo.processInfo.environment["CMUX_RESPECT_EXTERNAL_OPEN_RULES"] else {
return false
}
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
default:
return false
}
}()
if surfaceRaw != nil, subcommand == "open" {
// Treat `browser <surface> open <url>` as navigate for agent-browser ergonomics.
@ -2415,6 +2426,9 @@ struct CMUXCLI {
params["workspace_id"] = workspace
}
}
if respectExternalOpenRules {
params["respect_external_open_rules"] = true
}
if let windowRaw = windowOpt {
if let window = try normalizeWindowHandle(windowRaw, client: client) {
params["window_id"] = window

View file

@ -437,6 +437,7 @@ if [[ -n "$settings_domain" ]]; then
if [[ -n "$whitelist_raw" ]]; then
load_whitelist_patterns "$whitelist_raw"
fi
fi
# Find cmux CLI (same directory as this script).
@ -448,13 +449,15 @@ if [[ ! -x "$CMUX_CLI" ]]; then
fi
# Open each URL in cmux's in-app browser; track failures individually.
# External-open pattern rules are evaluated in-app (NSRegularExpression) so
# terminal link clicks and intercepted `open` commands share one regex dialect.
failed_urls=()
for url in "${cmux_targets[@]}"; do
if is_http_url "$url" && ! host_matches_whitelist "$url"; then
failed_urls+=("$url")
continue
fi
"$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
CMUX_RESPECT_EXTERNAL_OPEN_RULES=1 "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
done
# Fall back to system open for unmatched args and URLs that failed.

View file

@ -1368,6 +1368,11 @@ class GhosttyApp {
NSWorkspace.shared.open(url)
}
case let .embeddedBrowser(url):
if BrowserLinkOpenSettings.shouldOpenExternally(url) {
return performOnMain {
NSWorkspace.shared.open(url)
}
}
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
return performOnMain {
NSWorkspace.shared.open(url)

View file

@ -186,6 +186,8 @@ enum BrowserLinkOpenSettings {
static let browserHostWhitelistKey = "browserHostWhitelist"
static let defaultBrowserHostWhitelist: String = ""
static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns"
static let defaultBrowserExternalOpenPatterns: String = ""
static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil {
@ -226,6 +228,38 @@ enum BrowserLinkOpenSettings {
.filter { !$0.isEmpty }
}
static func externalOpenPatterns(defaults: UserDefaults = .standard) -> [String] {
let raw = defaults.string(forKey: browserExternalOpenPatternsKey) ?? defaultBrowserExternalOpenPatterns
return raw
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && !$0.hasPrefix("#") }
}
static func shouldOpenExternally(_ url: URL, defaults: UserDefaults = .standard) -> Bool {
shouldOpenExternally(url.absoluteString, defaults: defaults)
}
static func shouldOpenExternally(_ rawURL: String, defaults: UserDefaults = .standard) -> Bool {
let target = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !target.isEmpty else { return false }
for rawPattern in externalOpenPatterns(defaults: defaults) {
guard let (isRegex, value) = parseExternalPattern(rawPattern) else { continue }
if isRegex {
guard let regex = try? NSRegularExpression(pattern: value, options: [.caseInsensitive]) else { continue }
let range = NSRange(target.startIndex..<target.endIndex, in: target)
if regex.firstMatch(in: target, options: [], range: range) != nil {
return true
}
} else if target.range(of: value, options: [.caseInsensitive]) != nil {
return true
}
}
return false
}
/// Check whether a hostname matches the configured whitelist.
/// Empty whitelist means "allow all" (no filtering).
/// Supports exact match and wildcard prefix (`*.example.com`).
@ -264,6 +298,19 @@ enum BrowserLinkOpenSettings {
}
return host == pattern
}
private static func parseExternalPattern(_ rawPattern: String) -> (isRegex: Bool, value: String)? {
let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.lowercased().hasPrefix("re:") {
let regexPattern = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
guard !regexPattern.isEmpty else { return nil }
return (isRegex: true, value: regexPattern)
}
return (isRegex: false, value: trimmed)
}
}
enum BrowserInsecureHTTPSettings {

View file

@ -5252,6 +5252,7 @@ class TerminalController {
}
let urlStr = v2String(params, "url")
let url = urlStr.flatMap { URL(string: $0) }
let respectExternalOpenRules = v2Bool(params, "respect_external_open_rules") ?? false
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create browser", data: nil)
v2MainSync {
@ -5259,6 +5260,34 @@ class TerminalController {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
if let url,
respectExternalOpenRules,
BrowserLinkOpenSettings.shouldOpenExternally(url) {
guard NSWorkspace.shared.open(url) else {
result = .err(
code: "external_open_failed",
message: "Failed to open URL externally",
data: ["url": url.absoluteString]
)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": v2OrNull(nil),
"pane_ref": v2Ref(kind: .pane, uuid: nil),
"surface_id": v2OrNull(nil),
"surface_ref": v2Ref(kind: .surface, uuid: nil),
"created_split": false,
"placement_strategy": "external",
"opened_externally": true,
"url": url.absoluteString
])
return
}
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)

View file

@ -2730,6 +2730,8 @@ struct SettingsView: View {
@AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue()
@AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
@AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
@ -3354,6 +3356,31 @@ struct SettingsView: View {
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
SettingsCardDivider()
VStack(alignment: .leading, spacing: 6) {
SettingsCardRow(
"URLs to Always Open Externally",
subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage))."
) {
EmptyView()
}
TextEditor(text: $browserExternalOpenPatterns)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 60, maxHeight: 120)
.scrollContentBackground(.hidden)
.padding(6)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)
)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
}
SettingsCardDivider()
@ -3602,6 +3629,7 @@ struct SettingsView: View {
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser
browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled

View file

@ -7935,6 +7935,56 @@ final class BrowserLinkOpenSettingsTests: XCTestCase {
defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
}
func testExternalOpenPatternsDefaultToEmpty() {
XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty)
}
func testExternalOpenLiteralPatternMatchesCaseInsensitively() {
defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
XCTAssertTrue(
BrowserLinkOpenSettings.shouldOpenExternally(
"https://platform.OPENAI.com/account/usage",
defaults: defaults
)
)
}
func testExternalOpenRegexPatternMatchesCaseInsensitively() {
defaults.set(
"re:^https?://[^/]*\\.example\\.com/(billing|usage)",
forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
)
XCTAssertTrue(
BrowserLinkOpenSettings.shouldOpenExternally(
"https://FOO.example.com/BILLING",
defaults: defaults
)
)
}
func testExternalOpenRegexPatternSupportsDigitCharacterClass() {
defaults.set(
"re:^https://example\\.com/usage/\\d+$",
forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
)
XCTAssertTrue(
BrowserLinkOpenSettings.shouldOpenExternally(
"https://example.com/usage/42",
defaults: defaults
)
)
}
func testExternalOpenPatternsIgnoreInvalidRegexEntries() {
defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
XCTAssertTrue(
BrowserLinkOpenSettings.shouldOpenExternally(
"https://example.com/path",
defaults: defaults
)
)
}
}
final class TerminalOpenURLTargetResolutionTests: XCTestCase {

View file

@ -33,6 +33,7 @@ def run_wrapper(
intercept_setting: str | None,
legacy_open_setting: str | None = None,
whitelist: str | None,
external_patterns: str | None = None,
fail_urls: list[str] | None = None,
local_files: list[str] | None = None,
python_bin: str | None = None,
@ -87,6 +88,13 @@ case "$key" in
fi
exit 1
;;
browserExternalOpenPatterns)
if [[ "${FAKE_DEFAULTS_EXTERNAL_PATTERNS+x}" == "x" ]]; then
printf '%s' "$FAKE_DEFAULTS_EXTERNAL_PATTERNS"
exit 0
fi
exit 1
;;
*)
exit 1
;;
@ -148,6 +156,11 @@ exit 0
else:
env["FAKE_DEFAULTS_WHITELIST"] = whitelist
if external_patterns is None:
env.pop("FAKE_DEFAULTS_EXTERNAL_PATTERNS", None)
else:
env["FAKE_DEFAULTS_EXTERNAL_PATTERNS"] = external_patterns
if fail_urls:
env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls)
else:
@ -226,6 +239,95 @@ def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None:
expect(cmux_log == [f"browser open {url}"], f"whitelist match: unexpected cmux log {cmux_log}", failures)
def test_external_literal_pattern_is_deferred_to_app(failures: list[str]) -> None:
url = "https://platform.openai.com/account/usage"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="",
external_patterns="platform.openai.com/account/usage",
)
expect(code == 0, f"external literal deferred: wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [f"browser open {url}"],
f"external literal deferred: expected wrapper to pass URL to cmux, got {cmux_log}",
failures,
)
expect(
open_log == [],
f"external literal deferred: system open should not be called by wrapper, got {open_log}",
failures,
)
def test_external_regex_pattern_is_deferred_to_app(failures: list[str]) -> None:
url = "https://foo.example.com/billing"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="*.example.com",
external_patterns=r"re:^https?://[^/]*\.example\.com/(billing|usage)",
)
expect(code == 0, f"external regex deferred: wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [f"browser open {url}"],
f"external regex deferred: expected wrapper to pass URL to cmux, got {cmux_log}",
failures,
)
expect(
open_log == [],
f"external regex deferred: system open should not be called by wrapper, got {open_log}",
failures,
)
def test_external_regex_with_icu_features_is_deferred_to_app(failures: list[str]) -> None:
url = "https://example.com/usage/42"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="example.com",
external_patterns=r"re:^https://example\.com/usage/\d+$",
)
expect(code == 0, f"external regex icu deferred: wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [f"browser open {url}"],
f"external regex icu deferred: expected wrapper to pass URL to cmux, got {cmux_log}",
failures,
)
expect(
open_log == [],
f"external regex icu deferred: system open should not be called by wrapper, got {open_log}",
failures,
)
def test_external_invalid_regex_is_ignored_silently(failures: list[str]) -> None:
url = "https://example.com/path"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="",
external_patterns=r"re:[unclosed",
)
expect(code == 0, f"external invalid regex: wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [f"browser open {url}"],
f"external invalid regex: expected cmux open for {url}, got {cmux_log}",
failures,
)
expect(
open_log == [],
f"external invalid regex: expected no system open calls, got {open_log}",
failures,
)
expect(
"invalid regular expression" not in stderr.lower(),
f"external invalid regex: stderr should stay clean, got {stderr!r}",
failures,
)
def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None:
good = "https://api.example.com"
failed = "https://fail.example.com"
@ -468,6 +570,10 @@ def main() -> int:
test_toggle_disabled_case_insensitive_passthrough(failures)
test_whitelist_miss_passthrough(failures)
test_whitelist_match_routes_to_cmux(failures)
test_external_literal_pattern_is_deferred_to_app(failures)
test_external_regex_pattern_is_deferred_to_app(failures)
test_external_regex_with_icu_features_is_deferred_to_app(failures)
test_external_invalid_regex_is_ignored_silently(failures)
test_partial_failures_only_fallback_failed_urls(failures)
test_legacy_toggle_fallback_passthrough(failures)
test_legacy_toggle_fallback_case_insensitive_passthrough(failures)