Skip to content

Check the owner of the Claude shim directory in the app, workspace commands and nushell - #15185

Merged
teamleaderleo merged 8 commits into
mainfrom
claude-shim-owner-checks
Sep 28, 2026
Merged

teamleaderleo merged 8 commits into
mainfrom
claude-shim-owner-checks

Conversation

@austinywang

@austinywang austinywang commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This follows #15179, which added owner checks for the Claude command shim directory (cmux-cli-shims/<surface>) in the zsh, bash and fish integrations. It covers the three places #15179 left as follow-ups. Each of them still trusted that directory:

  • the app, which writes the shims into it;
  • a workspace's initial command, which puts it first on PATH;
  • the nushell bootstrap, which moves it to the front of PATH.

In a shared temporary directory, another user could create cmux-cli-shims or a surface directory first, or put a symlink there. Now each place checks the directory first. If the check fails, it skips the shim and leaves PATH alone. Nothing moves to a new location.

App shim writer. TerminalSurface.installAgentCommandShimsIfPossible now checks three directories: the shim parent, the staging directory and the final surface directory. It uses a new PrivateDirectoryCheck helper in CmuxFoundation, which:

  • opens the path with O_NOFOLLOW | O_DIRECTORY;
  • keeps it only if it's a directory owned by the effective user, then sets it to 0700 with fchmod;
  • confirms with lstat that the path still names that directory and that group and others can't write to it.

If any directory fails, the installer returns nil and the surface starts without the shim. The old code set permissions with FileManager.setAttributes, which follows symlinks. The helper is in CmuxFoundation so its tests can run without GhosttyKit.

Workspace initial command. WorkspaceInitialCommandLoginShell prepends $CMUX_CLAUDE_WRAPPER_SHIM_ROOT only when it's a directory, isn't a symlink and belongs to this user. In POSIX shells the check is [ -d ] && [ ! -L ] && [ -O ]. In fish it's test -d, not test -L and test -O.

Nushell. Nushell has no built-in owner check, so the bootstrap asks /usr/bin/stat. Without -L, stat doesn't follow a symlink. The bootstrap used to move every PATH entry containing cmux-cli-shims to the front. Now it moves only the surface's shim root, and only when stat reports a directory owned by id -u. Other entries keep their order.

The bootstrap looks for the shim root in two variables:

  • $CMUX_AGENT_COMMAND_SHIM_ROOT, which the app sets whenever any agent shim is installed;
  • $CMUX_CLAUDE_WRAPPER_SHIM_ROOT, which the app sets only for the Claude shim.

With Claude integration off, the Codex, Pi, Amp and Hermes shims keep their place ahead of the user's PATH prepends.

Known limits:

  • The workspace-command and nushell checks don't look at group or other write bits. The app writer already makes the directory 0700.
  • The workspace initial command still prepends only $CMUX_CLAUDE_WRAPPER_SHIM_ROOT, the same as on main.

#14642 edits TerminalSurface+AgentCommandShims.swift, its permissions tests and tests/test-execution.toml. Whichever PR lands second will need a rebase.

Testing

Each fix comes after its failing test in the commit history. For each pair below, the red and green results come from the same command.

Area Command Red Green
App shim writer (helper) swift test --package-path Packages/macOS/CmuxFoundation --filter PrivateDirectoryCheckTests b97e33bbf7: 5 tests, 3 failed with 6 issues (symlink, other owner, regular file) 8df112be57: 5 passed
Workspace initial command python3 tests/test_workspace_initial_command_shim_root_owner.py d6ea06f94b: 5 tests, FAILED (failures=12); the symlink and other-owner cases failed in zsh, bash, sh, ksh, dash and fish 1f92ca694f: 5 tests, OK
Nushell (owner checks) CMUX_TEST_NU_BIN=<nu 0.113.1> python3 tests/test_nushell_shim_path_refront.py main's bootstrap: failed at the symlinked-root case 5905e00cfd: all cases passed
Nushell (without Claude integration) same command be36a5785b: failed; which claude found the user's decoy, not the shim c6fd61da22: all 7 cases passed

About the tests:

  • Workspace-command test. It compiles WorkspaceInitialCommandLoginShell.swift with a small driver and runs the wrapped command in each available login shell. It's registered in the macos-shell lane. Its other-owner case uses /usr/share, which root owns.

  • Nushell test. It ran with nushell 0.113.1, the checksum-verified release binary CI pins. It has four new cases:

    • a symlinked root;
    • a root another user owns (/usr/share);
    • other cmux-cli-shims entries staying where they are;
    • a shim root that only $CMUX_AGENT_COMMAND_SHIM_ROOT names.

    The other-owner case passes on main too, because the old bootstrap never moved /usr/share. tests/test_nushell_integration_hooks.py and tests/test_nushell_resume_command_dialect.py also pass with the new bootstrap.

At 5905e00cfd:

  • python3 scripts/verify-local.py --affected origin/main --swift-changed origin/main passed 15/15 checks.
  • python3 scripts/ci/validate_test_execution_registry.py passed.
  • python3 tests/test_workspace_initial_command_shim_root_owner.py passed.

At head c6fd61da22, python3 scripts/verify-local.py --affected origin/main passed 14/14 checks.

Not run:

  • CmuxTerminal package tests. These include the two new symlink cases in TerminalSurfaceCommandShimPermissionsTests. GhosttyKit.xcframework isn't available locally, so the TerminalSurface+AgentCommandShims.swift change hasn't been compiled either.
  • cmuxTests. The six exact-string wrap tests in WorkspaceCreateWorkingDirectoryTests were updated but not run. Their expected strings were checked against the compiled driver's output.
  • No app build. Nothing was checked live in a tagged build.

No user-facing strings changed.

Changelog

  • Fixed: cmux no longer trusts a Claude command shim directory another user owns.

Checklist

  • Behavior changes have added or updated tests, or Testing says why not
  • Reviewed with a subagent before merge (cmux-review), and all bot and human review comments resolved

🤖 Generated with Claude Code


Summary by cubic

This extends the Claude shim directory owner checks from the existing shell integrations to the three places that still trusted it: the app that writes the shims, a workspace's initial command that prepends them to PATH, and the nushell bootstrap that refronts them. Each now uses the directory only when it is a real directory this user owns; otherwise the surface starts without the shim and PATH keeps its order.

Behavior changes

  • The app shim writer uses a new PrivateDirectoryCheck helper that opens paths without following symlinks, sets mode 0700 via fchmod, and re-verifies ownership with lstat; it replaces FileManager.setAttributes, which followed symlinks.
  • Workspace login-shell wrappers (POSIX and fish) prepend $CMUX_CLAUDE_WRAPPER_SHIM_ROOT only when directory, not-symlink, and owned-by-user checks pass.
  • Nushell moves only $CMUX_CLAUDE_WRAPPER_SHIM_ROOT to the front of PATH, and only when /usr/bin/stat (which doesn't follow symlinks) reports a directory owned by the user; other cmux-cli-shims entries stay in place.

Limits

  • The workspace and nushell checks don't test group or other write bits; the app writer sets the directory to 0700 first.
  • When Claude integration is off, CMUX_CLAUDE_WRAPPER_SHIM_ROOT isn't set, so nushell no longer moves that directory to the front of PATH.
  • Keep per-pane Claude shims outside TMPDIR #14642 edits the same app shim files; whichever PR merges second needs a rebase.

Written for commit 5905e00. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Security
    • Shim directories are now checked to ensure they are real directories owned by the current user before they are installed or added to the shell search path.
    • Symlinked, missing, incorrectly owned, or non-directory paths are rejected, helping prevent unsafe shim use.
    • Shell path order is preserved except when a validated shim directory is moved to the front.

austinywang and others added 6 commits September 27, 2026 22:44
Move the shim directory chmod calls onto a small PrivateDirectoryCheck
helper that still follows the old behavior, and add tests for a
symlinked directory, a directory another user owns and a regular file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shim installer now opens the shim parent, the staging directory and
the published surface directory without following a symlink, and uses
each one only when it is a real directory this user owns. Anything else
skips the shim instead of writing into or changing that directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run the login-shell wrapper for a workspace's initial command in each
available shell and check that CMUX_CLAUDE_WRAPPER_SHIM_ROOT lands first
on PATH only when it is a real directory this user owns. Update the
exact-string wrapper tests to the owner-checked form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The login-shell wrapper for a workspace's initial command now puts
CMUX_CLAUDE_WRAPPER_SHIM_ROOT on PATH only when it is a directory, not a
symlink, and owned by this user, in both the POSIX and fish forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bootstrap should move only $CMUX_CLAUDE_WRAPPER_SHIM_ROOT, and only
when it is a real directory this user owns. Other cmux-cli-shims entries
keep their place in PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nushell bootstrap moved every PATH entry containing cmux-cli-shims to
the front. It now moves only $CMUX_CLAUDE_WRAPPER_SHIM_ROOT, and only when
stat reports a real directory owned by this user. Otherwise PATH keeps its
order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 4a4b2831-95c9-48b1-9f32-d50041749641

📥 Commits

Reviewing files that changed from the base of the PR and between 47a223c and c6fd61d.

📒 Files selected for processing (11)
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/PrivateDirectoryCheck.swift
  • Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/PrivateDirectoryCheckTests.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+AgentCommandShims.swift
  • Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceCommandShimPermissionsTests.swift
  • Resources/shell-integration/nushell/cmux-nushell-bootstrap.nu
  • Sources/WorkspaceInitialCommandLoginShell.swift
  • cmuxTests/WorkspaceCreateWorkingDirectoryTests.swift
  • tests/fixtures/WorkspaceInitialCommandLoginShellFixture.swift
  • tests/test-execution.toml
  • tests/test_nushell_shim_path_refront.py
  • tests/test_workspace_initial_command_shim_root_owner.py
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

austinywang and others added 2 commits September 27, 2026 22:59
The app sets CMUX_AGENT_COMMAND_SHIM_ROOT whenever any agent shim is
installed, but CMUX_CLAUDE_WRAPPER_SHIM_ROOT only when the Claude shim is.
With Claude integration off, the Codex, Pi, Amp and Hermes shims must still
move ahead of the user's PATH prepends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consider CMUX_AGENT_COMMAND_SHIM_ROOT as well as
CMUX_CLAUDE_WRAPPER_SHIM_ROOT, so the Codex, Pi, Amp and Hermes shims keep
their place ahead of user PATH prepends when the Claude shim is off. Each
root still moves only when it is a directory this user owns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

CI failure attribution

CI passes on c6fd61da22 (run 36384441972 attempt 2).

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Review: security review. Merging this, it is a clear net improvement over main, which had no owner check at all. One divergence between the three implementations is worth tightening, but it is narrower than what the PR closes and you already name it in Known limits, so I would rather have this in than hold it.

The divergence, for the record

The app's check is the strong one, PrivateDirectoryCheck.swift:46:

&& current.st_uid == owner
&& current.st_mode & (S_IWGRP | S_IWOTH) == 0

plus it forces the directory to 0700 with fchmod before trusting it.

The two shell-level checks stop at ownership. WorkspaceInitialCommandLoginShell.swift:48,67:

[ -d "$ROOT" ] && [ ! -L "$ROOT" ] && [ -O "$ROOT" ]

and nushell compares stat -f "%HT:%u" against Directory:<uid> and nothing else.

So a shim root owned by the invoking user but group- or world-writable is refused by the app's own writer and trusted by both shells. That matters on a shared Mac because macOS puts every local account in staff, so another local user in that group can drop a claude file, or a symlink to one, into a group-writable shim root, and it runs as the victim at the next login shell or nushell start. In practice the app creates these directories and forces 0700, so the reachable case is a leftover directory from a build that predates this hardening, or one loosened by something else. Narrow, but it is the classic "three implementations, the weakest one is the policy" shape and the reason I would close it rather than leave it.

Porting the bit test is small: stat -f %Lp in nushell, and an explicit permission check alongside the existing tests in the POSIX and fish payloads.

What I checked that holds up

Fails closed everywhere. PrivateDirectoryCheck returns false on any failure across open, fstat, fchmod and lstat. The POSIX and fish && and and chains short-circuit to not-prepending on any failed test. The nushell version wraps the whole computation in try { } catch { false } and returns false immediately for a non-absolute root.

Owner acceptance is correctly narrow: all three require the current euid only, and none of them accept root or any other owner. Your /usr/share test case covers that.

Symlink handling is genuinely careful, and this is the part I liked most. The app opens with O_NOFOLLOW|O_DIRECTORY, and TerminalSurface+AgentCommandShims.swift:173-174 re-checks the destination with makePrivate immediately before replaceItemAt, which is exactly the right move since replaceItemAt will otherwise happily follow a symlink at the destination. installSkipsSymlinkedShimParent and installSkipsSymlinkedSurfaceDirectory cover it, and test_symlink_is_not_prepended covers the shell side.

Nothing legitimate breaks. The PR only touches app-generated per-surface directories under the user's private temp directory, never Homebrew's paths or root-owned ones, so Homebrew installs, admin-group setups and multi-user Macs are unaffected.

Also worth noting, not a defect

Trust is established once at shell start and never re-verified when claude is actually resolved from PATH later, and the -d/-L/-O tests are three separate non-atomic stats. That stat-then-exec window only matters if the parent directory is writable by someone else, which collapses back into the group-writable case above, so fixing that one closes this too.

The workspace-command wrapper only prepends CMUX_CLAUDE_WRAPPER_SHIM_ROOT while nushell refronts both roots. That is a scope difference, not a weaker check.

Fixed: nothing needed to merge.

Left: the group and other write-bit check in the POSIX, fish and nushell payloads, plus a test with an owned-but-group-writable directory for each, since none of the current tests cover that shape. Worth a follow-up issue so it does not get lost.

@teamleaderleo
teamleaderleo merged commit fd96369 into main Sep 28, 2026
106 of 111 checks passed
@teamleaderleo
teamleaderleo deleted the claude-shim-owner-checks branch September 28, 2026 08:27
@github-actions

Copy link
Copy Markdown
Contributor

Merge receipt for c6fd61da22: every check was green at merge (21 verified; 17 skipped by policy). Full suite runs on main after merge.

rustybret pushed a commit to rustybret/bmux that referenced this pull request Sep 28, 2026
ba94a13 CI: let Iroh release gate reuse unchanged TUI artifact
71a921c fix(web): stop orphaned Cloud VM alert pages (manaflow-ai#15138)
9971c2c Keep newer iOS connections alive when a recovery is superseded (manaflow-ai#15141)
c307ab0 cmux-tui: only connect to derived local sockets served by this user (manaflow-ai#15144)
1220252 codex-teams: keep the watcher's socket password out of its arguments (manaflow-ai#15140)
b3a73f0 chatmux-relay: keep cmux-tui sockets and journal cursors private to this user (manaflow-ai#15156)
b0d5083 ci: dispatch UI tests from a default-branch workflow; PR CI keeps no write token (manaflow-ai#15226)
1255448 test: fix three app-host tests that keep main red (manaflow-ai#15204)
0fc4975 test: pin the fixture PATH inside the zsh watcher sleep test (manaflow-ai#15237)
758aaeb fix(ios): clear read notifications on foreground return (manaflow-ai#14725)
4c15bb3 cmux-browser: stop requiring GPL for web/package.json (manaflow-ai#15231)
97fe6b4 test: keep the Cloud notification harness workspace unselected (manaflow-ai#15215)
61083e3 test: keep workspace cwd inheritance tests off the shared standard defaults (manaflow-ai#15227)
eae4994 Pin password badge actions to their source runtime (manaflow-ai#14921)
fd96369 Check the owner of the Claude shim directory in the app, workspace commands and nushell (manaflow-ai#15185)
0ebf8d7 Fix main-thread freeze during SSH paste detection (manaflow-ai#15113)
a98c560 test: pin font magnification in the Cloud outline attention test (manaflow-ai#15213)
austinywang added a commit that referenced this pull request Sep 28, 2026
tests/test-execution.toml: keep this branch's shell shim test and
main's workspace shim root owner test (#15185) as separate macos-shell
entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants