Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
public import Darwin

/// Makes a directory private to one user before cmux writes into it, such as
/// the per-surface agent command shim directories under a temporary directory.
///
/// A directory in a shared temporary directory can already exist under another
/// user's control, so the path is opened without following a symlink and kept
/// only when it is a real directory this user owns. It is then set to 0700.
///
/// ```swift
/// guard PrivateDirectoryCheck().makePrivate(atPath: directory.path) else { return nil }
/// ```
public struct PrivateDirectoryCheck: Sendable {
/// The user that must own the directory.
public let owner: uid_t

/// Creates a check for directories owned by `owner`, the effective user by default.
public init(owner: uid_t = geteuid()) {
self.owner = owner
}

/// Sets the directory at `path` to mode 0700 when it is a real directory
/// owned by ``owner``.
///
/// - Returns: `true` when `path` is still that directory, not a symlink,
/// owned by ``owner`` and writable by no one else; otherwise `false`,
/// without changing anything the path does not own.
public func makePrivate(atPath path: String) -> Bool {
let fd = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
guard fd >= 0 else { return false }
defer { close(fd) }
var opened = stat()
guard fstat(fd, &opened) == 0,
(opened.st_mode & S_IFMT) == S_IFDIR,
opened.st_uid == owner,
fchmod(fd, 0o700) == 0 else {
return false
}
// The path must still name the directory that was opened and changed.
var current = stat()
guard lstat(path, &current) == 0 else { return false }
return current.st_dev == opened.st_dev
&& current.st_ino == opened.st_ino
&& (current.st_mode & S_IFMT) == S_IFDIR
&& current.st_uid == owner
&& current.st_mode & (S_IWGRP | S_IWOTH) == 0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Darwin
import Foundation
import Testing
@testable import CmuxFoundation

@Suite struct PrivateDirectoryCheckTests {
private let directory: URL

init() throws {
directory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-private-directory-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}

private func path(_ name: String) -> String {
directory.appendingPathComponent(name).path
}

private func makeDirectory(_ name: String, mode: mode_t) throws -> String {
let path = path(name)
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false)
#expect(chmod(path, mode) == 0)
return path
}

private func mode(atPath path: String) -> mode_t? {
var info = stat()
guard lstat(path, &info) == 0 else { return nil }
return info.st_mode & 0o7777
}

@Test func tightensAnOwnedDirectory() throws {
defer { try? FileManager.default.removeItem(at: directory) }
let shared = try makeDirectory("shared", mode: 0o775)
#expect(PrivateDirectoryCheck().makePrivate(atPath: shared))
#expect(mode(atPath: shared) == 0o700)
}

@Test func rejectsASymlinkAndLeavesItsTarget() throws {
defer { try? FileManager.default.removeItem(at: directory) }
let target = try makeDirectory("target", mode: 0o755)
let link = path("link")
try FileManager.default.createSymbolicLink(atPath: link, withDestinationPath: target)
#expect(!PrivateDirectoryCheck().makePrivate(atPath: link))
#expect(mode(atPath: target) == 0o755)
}

@Test func rejectsADirectoryAnotherUserOwns() throws {
defer { try? FileManager.default.removeItem(at: directory) }
let foreign = try makeDirectory("foreign", mode: 0o755)
let check = PrivateDirectoryCheck(owner: geteuid() &+ 1)
#expect(!check.makePrivate(atPath: foreign))
#expect(mode(atPath: foreign) == 0o755)
}

@Test func rejectsARegularFile() throws {
defer { try? FileManager.default.removeItem(at: directory) }
let file = path("file")
try "kept\n".write(toFile: file, atomically: false, encoding: .utf8)
#expect(chmod(file, 0o644) == 0)
#expect(!PrivateDirectoryCheck().makePrivate(atPath: file))
#expect(mode(atPath: file) == 0o644)
}

@Test func rejectsAMissingPath() {
defer { try? FileManager.default.removeItem(at: directory) }
#expect(!PrivateDirectoryCheck().makePrivate(atPath: path("missing")))
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
public import Foundation
import CmuxFoundation
public import CmuxTerminalCore

extension TerminalSurface {
Expand Down Expand Up @@ -119,12 +120,15 @@ extension TerminalSurface {
defer {
try? fileManager.removeItem(at: stagingDirectory)
}
// A shared temporary directory lets another user create these
// directories first or plant a symlink, so each one must be a real
// directory this user owns before anything is written into it.
let privateDirectoryCheck = PrivateDirectoryCheck()
do {
try fileManager.createDirectory(at: shimParentDirectory, withIntermediateDirectories: true)
guard privateDirectoryCheck.makePrivate(atPath: shimParentDirectory.path) else { return nil }
try fileManager.createDirectory(at: stagingDirectory, withIntermediateDirectories: false)
for directory in [shimParentDirectory, stagingDirectory] {
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
}
guard privateDirectoryCheck.makePrivate(atPath: stagingDirectory.path) else { return nil }
} catch {
return nil
}
Expand Down Expand Up @@ -167,6 +171,7 @@ extension TerminalSurface {
guard !shims.isEmpty else { return nil }
do {
if fileManager.fileExists(atPath: shimDirectory.path) {
guard privateDirectoryCheck.makePrivate(atPath: shimDirectory.path) else { return nil }
_ = try fileManager.replaceItemAt(
shimDirectory,
withItemAt: stagingDirectory,
Expand All @@ -176,10 +181,10 @@ extension TerminalSurface {
} else {
try fileManager.moveItem(at: stagingDirectory, to: shimDirectory)
}
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shimDirectory.path)
} catch {
return nil
}
guard privateDirectoryCheck.makePrivate(atPath: shimDirectory.path) else { return nil }
return TerminalSurfaceAgentCommandShimSet(
directoryPath: shimDirectory.path,
shims: shims
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,75 @@ struct TerminalSurfaceCommandShimPermissionsTests {
}
}

@Test("Install skips a symlinked shim parent")
func installSkipsSymlinkedShimParent() throws {
let fileManager = FileManager.default
let root = URL.temporaryDirectory.appending(
path: "TerminalSurfaceCommandShimSymlinkParentTests-\(UUID().uuidString)",
directoryHint: .isDirectory
)
let temporaryDirectory = root.appending(path: "tmp", directoryHint: .isDirectory)
let parentDirectory = temporaryDirectory.appending(
path: "cmux-cli-shims",
directoryHint: .isDirectory
)
let linkTarget = root.appending(path: "elsewhere", directoryHint: .isDirectory)
let wrapperDirectory = try makeClaudeWrapperDirectory(in: root)
defer { try? fileManager.removeItem(at: root) }

for directory in [temporaryDirectory, linkTarget] {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: linkTarget.path)
try fileManager.createSymbolicLink(at: parentDirectory, withDestinationURL: linkTarget)

let shims = TerminalSurface.installAgentCommandShimsIfPossible(
wrapperDirectoryURL: wrapperDirectory,
surfaceId: UUID(),
temporaryDirectory: temporaryDirectory,
fileManager: fileManager
)
#expect(shims == nil)
#expect(try fileManager.contentsOfDirectory(atPath: linkTarget.path).isEmpty)
#expect(try posixPermissions(atPath: linkTarget.path) == 0o755)
}

@Test("Install skips a symlinked surface directory")
func installSkipsSymlinkedSurfaceDirectory() throws {
let fileManager = FileManager.default
let root = URL.temporaryDirectory.appending(
path: "TerminalSurfaceCommandShimSymlinkSurfaceTests-\(UUID().uuidString)",
directoryHint: .isDirectory
)
let temporaryDirectory = root.appending(path: "tmp", directoryHint: .isDirectory)
let parentDirectory = temporaryDirectory.appending(
path: "cmux-cli-shims",
directoryHint: .isDirectory
)
let surfaceId = UUID()
let shimDirectory = parentDirectory.appending(path: surfaceId.uuidString, directoryHint: .isDirectory)
let linkTarget = root.appending(path: "elsewhere", directoryHint: .isDirectory)
let wrapperDirectory = try makeClaudeWrapperDirectory(in: root)
defer { try? fileManager.removeItem(at: root) }

for directory in [parentDirectory, linkTarget] {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: parentDirectory.path)
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: linkTarget.path)
try fileManager.createSymbolicLink(at: shimDirectory, withDestinationURL: linkTarget)

let shims = TerminalSurface.installAgentCommandShimsIfPossible(
wrapperDirectoryURL: wrapperDirectory,
surfaceId: surfaceId,
temporaryDirectory: temporaryDirectory,
fileManager: fileManager
)
#expect(shims == nil)
#expect(try fileManager.contentsOfDirectory(atPath: linkTarget.path).isEmpty)
#expect(try posixPermissions(atPath: linkTarget.path) == 0o755)
}

@Test("Claude integration toggle controls the per-surface shim")
func claudeIntegrationToggleControlsPerSurfaceShim() throws {
let fileManager = FileManager.default
Expand Down Expand Up @@ -401,6 +470,21 @@ struct TerminalSurfaceCommandShimPermissionsTests {
#expect(scanCounter.value == 2)
}

private func makeClaudeWrapperDirectory(in root: URL) throws -> URL {
let fileManager = FileManager.default
let wrapperDirectory = root.appending(path: "bin", directoryHint: .isDirectory)
let wrapper = wrapperDirectory.appending(path: "cmux-claude-wrapper", directoryHint: .notDirectory)
try fileManager.createDirectory(at: wrapperDirectory, withIntermediateDirectories: true)
try "#!/bin/sh\nexit 0\n".write(to: wrapper, atomically: true, encoding: .utf8)
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: wrapper.path)
return wrapperDirectory
}

private func posixPermissions(atPath path: String) throws -> UInt16 {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
return try #require(attributes[.posixPermissions] as? NSNumber).uint16Value
}

private func capturedArguments(
from shim: TerminalSurfaceAgentCommandShim,
logURL: URL,
Expand Down
18 changes: 13 additions & 5 deletions Resources/shell-integration/nushell/cmux-nushell-bootstrap.nu
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@
#
# User config commonly rebuilds PATH with its own prepends, which shadows the
# per-surface cmux-cli-shims directory cmux front-loaded at spawn (the claude
# wrapper that injects session tracking + notification hooks). Re-front every
# shim entry, preserving the relative order of everything else — nushell's
# wrapper that injects session tracking + notification hooks). Re-front that
# directory, preserving the relative order of everything else — nushell's
# equivalent of the zsh integration's "keep the bundled wrapper ahead of later
# PATH mutations". Also normalizes PATH back to a list when user config left
# it a colon-joined string.
def --env _cmux_refront_cli_shims [] { if ($env.CMUX_SURFACE_ID? | default "") == "" { return }; let raw = ($env.PATH? | default []); let entries = if ($raw | describe | str starts-with "list") { $raw } else { $raw | split row (char esep) }; let shims = ($entries | where {|p| $p | str contains "cmux-cli-shims" }); $env.PATH = ($shims ++ ($entries | where {|p| not ($p | str contains "cmux-cli-shims") })) }
# PATH mutations". The app sets $CMUX_AGENT_COMMAND_SHIM_ROOT whenever any
# agent shim exists and $CMUX_CLAUDE_WRAPPER_SHIM_ROOT only for the Claude
# shim, so both are candidates. Also normalizes PATH back to a list when user
# config left it a colon-joined string.
#
# The shim root can sit in a shared temporary directory, so it moves only when
# it is a real directory (not a symlink) owned by this user. nushell has no
# owner check of its own; stat, which does not follow a symlink here, reports
# the type and owner. When that can't be confirmed, PATH keeps its order.
def _cmux_owned_shim_root [root: string] { if not ($root | str starts-with "/") { return false }; try { let found = (^/usr/bin/stat -f "%HT:%u" -- $root | complete); let uid = (^/usr/bin/id -u | complete); $found.exit_code == 0 and $uid.exit_code == 0 and ($found.stdout | str trim) == $"Directory:($uid.stdout | str trim)" } catch { false } }
def --env _cmux_refront_cli_shims [] { if ($env.CMUX_SURFACE_ID? | default "") == "" { return }; let raw = ($env.PATH? | default []); let entries = if ($raw | describe | str starts-with "list") { $raw } else { $raw | split row (char esep) }; let roots = ([($env.CMUX_AGENT_COMMAND_SHIM_ROOT? | default ""), ($env.CMUX_CLAUDE_WRAPPER_SHIM_ROOT? | default "")] | uniq | where {|r| ($r in $entries) and (_cmux_owned_shim_root $r) }); $env.PATH = ($roots ++ ($entries | where {|p| $p not-in $roots })) }
_cmux_refront_cli_shims
16 changes: 10 additions & 6 deletions Sources/WorkspaceInitialCommandLoginShell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ enum WorkspaceInitialCommandLoginShell {
/// Login profiles can prepend other tool directories (Homebrew's `shellenv` puts
/// `/opt/homebrew/bin` first) ahead of the per-surface shim directory that cmux
/// seeds into the spawned PATH, which would route `claude`/`codex` around cmux's
/// wrapper hooks. The payload therefore re-prepends the shim directory
/// unconditionally after profiles run; a duplicate PATH entry is harmless and
/// matches what interactive shell integration already produces.
/// wrapper hooks. The payload therefore re-prepends the shim directory after
/// profiles run; a duplicate PATH entry is harmless and matches what interactive
/// shell integration already produces. The directory can sit in a shared
/// temporary directory, so it is prepended only when it is a real directory
/// (not a symlink) owned by this user.
static func wrap(_ command: String, userShell: String?) -> String {
var shellPath: String
if let userShell, userShell.hasPrefix("/") {
Expand All @@ -43,25 +45,27 @@ enum WorkspaceInitialCommandLoginShell {
switch (shellPath as NSString).lastPathComponent {
case "fish":
payload = """
if test -n "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT"; and test -d "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT"; set -gx PATH "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT" $PATH; end
if test -n "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT"; and test -d "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT"; and not test -L "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT"; and test -O "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT"; set -gx PATH "$CMUX_CLAUDE_WRAPPER_SHIM_ROOT" $PATH; end
\(command)
"""
case "zsh", "bash", "sh", "ksh", "dash":
payload = """
if [ -n "${CMUX_CLAUDE_WRAPPER_SHIM_ROOT:-}" ] && [ -d "${CMUX_CLAUDE_WRAPPER_SHIM_ROOT}" ]; then PATH="${CMUX_CLAUDE_WRAPPER_SHIM_ROOT}${PATH:+:$PATH}"; export PATH; fi
\(posixShimRootPrepend)
\(command)
"""
default:
shellPath = "/bin/zsh"
payload = """
if [ -n "${CMUX_CLAUDE_WRAPPER_SHIM_ROOT:-}" ] && [ -d "${CMUX_CLAUDE_WRAPPER_SHIM_ROOT}" ]; then PATH="${CMUX_CLAUDE_WRAPPER_SHIM_ROOT}${PATH:+:$PATH}"; export PATH; fi
\(posixShimRootPrepend)
\(command)
"""
}

return "\(shellSingleQuoted(shellPath)) -lc \(shellSingleQuoted(payload))"
}

private static let posixShimRootPrepend = #"if [ -n "${CMUX_CLAUDE_WRAPPER_SHIM_ROOT:-}" ] && [ -d "${CMUX_CLAUDE_WRAPPER_SHIM_ROOT}" ] && [ ! -L "${CMUX_CLAUDE_WRAPPER_SHIM_ROOT}" ] && [ -O "${CMUX_CLAUDE_WRAPPER_SHIM_ROOT}" ]; then PATH="${CMUX_CLAUDE_WRAPPER_SHIM_ROOT}${PATH:+:$PATH}"; export PATH; fi"#

private static func shellSingleQuoted(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
Expand Down
Loading
Loading