Skip to content

fix(install): refuse a root install instead of publishing a broken moshcode - #106

Merged
ralyodio merged 1 commit into
masterfrom
fix/installer-root-guard
Aug 3, 2026
Merged

fix(install): refuse a root install instead of publishing a broken moshcode#106
ralyodio merged 1 commit into
masterfrom
fix/installer-root-guard

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Why

public/install.sh is what moshcoding.com/install.sh serves, so this is the script everyone actually runs. Every path in it derives from $HOME. Escalated, that is /root — the CLI lands in /root/.moshcode and the wrapper in /root/.local/bin, mode 0700.

link_system_bin then makes it worse than a no-op. $MOSHCODE_BIN is not on root's PATH, but /usr/local/bin is and is writable, so it publishes:

/usr/local/bin/moshcode -> /root/.local/bin/moshcode

moshcode now resolves on PATH for every user on the box and executes for none of them. The install prints Install complete and exits 0. The first sign of trouble arrives later, on an unrelated command:

$ moshcode install secrets
zsh: permission denied: moshcode

That is not a hypothetical — it is a real machine, and the symlink above is copied from it.

It is also easy to walk into. dns enable genuinely needs root and tells you to re-run under sudo (dns.mjs:2405), and moshcode update self-updates by re-running this very installer (upgrade.mjs selfSpec). So an escalated update quietly reinstalls the CLI into root's home. The tool trains the habit, and one of the commands it applies to is the self-updater.

What

Refuse before any work, and name the user who would be locked out:

✗ don't install moshcode with sudo.

  Every path here comes from $HOME, which sudo has set to /root, so this
  would install for root and leave anthony with a moshcode on PATH that
  it cannot execute.

  Run it as yourself instead:
      curl -fsSL https://moshcoding.com/install.sh | sh

  For a deliberate system-wide install, name the paths explicitly:
      sudo MOSHCODE_ALLOW_ROOT=1 MOSHCODE_HOME=/opt/moshcode \
           MOSHCODE_BIN=/usr/local/bin sh -c 'curl -fsSL https://moshcoding.com/install.sh | sh'

Deliberately narrow, so nothing that works today stops working:

  • a bare root shell still installs — containers, CI images and root-only VPS boxes have no SUDO_USER
  • MOSHCODE_ALLOW_ROOT=1 overrides even the refused case
  • remove is left unguarded — cleaning up an existing root install is precisely when running as root is correct

Tests

tests/install-root-guard.test.mjs drives the real script with id shadowed on PATH, so its root branch is exercised without root, and curl/mise are tripwires.

The load-bearing assertion is that the refusal lands before detect_os, the first step of run_install — not merely that it happens. A guard that fires after the install has started is not a guard, and this bug is specifically a half-completed install that reports success.

Seven cases: refusal fires and nothing runs; the message names the locked-out user and both remedies; bare root still proceeds; MOSHCODE_ALLOW_ROOT overrides; a normal uid is unaffected even with SUDO_USER set (a plain shell inherits it after any earlier escalation); run_remove stays unguarded; the header documents the override.

Verified non-vacuous — reverting the guard fails 9 assertions.

Suite: 163 pass / 7 fail, and those same 7 fail on master without this branch (baseline 156 pass / 7 fail). sh -n public/install.sh clean.

Related

moshcoder/moshcode#272 adds the same guard to that repo's install.sh. I opened it first believing it was the served script; it is not — this one is. That PR is still worth having as defense-in-depth, since its header advertises a raw.githubusercontent.com install path, but this is the fix for the bug.

🤖 Generated with Claude Code

…shcode

public/install.sh is what moshcoding.com/install.sh serves, so this is
the script everyone actually runs. Every path in it comes from $HOME.
Escalated, that is /root: the CLI lands in /root/.moshcode and the
wrapper in /root/.local/bin, mode 0700.

link_system_bin then makes it worse than a no-op. Because $MOSHCODE_BIN
is not on root's PATH but /usr/local/bin is and is writable, it
publishes

    /usr/local/bin/moshcode -> /root/.local/bin/moshcode

so `moshcode` resolves on PATH for every user on the box and executes
for none of them. The install prints "Install complete" and exits 0. The
first sign of trouble arrives later, on an unrelated command:

    $ moshcode install secrets
    zsh: permission denied: moshcode

This is easy to walk into: `dns enable` needs root and tells you to
re-run under sudo, and `moshcode update` self-updates by re-running this
installer (upgrade.mjs selfSpec) -- so an escalated update quietly
reinstalls the CLI into root's home.

Refuse that case before any work, and name the user who would be locked
out. A bare root shell (containers, CI images, root-only VPS) has no
SUDO_USER and is a legitimate way to install, so only the
escalated-from-a-real-user case is refused, and MOSHCODE_ALLOW_ROOT
overrides even that. `remove` is deliberately left unguarded, since
cleaning up an existing root install is exactly when running as root is
right.

The tests drive the real script with `id` shadowed on PATH, so the root
branch runs without root. They assert the refusal lands before detect_os
-- the first step of run_install -- rather than merely that it happens,
because a guard that fires after the install has started is not a guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

15 finding(s)

HIGH/CRITICAL: 9 | MEDIUM: 6

Severity Rule Location
HIGH secret-generic-credential tests/domain-webhook-active.test.mjs:18
HIGH secret-generic-credential tests/domain-webhook-active.test.mjs:25
HIGH secret-generic-credential tests/domain-webhook-active.test.mjs:30
HIGH secret-generic-credential tests/moshcode-webhook-signature.test.mjs:7
HIGH secret-generic-credential tests/moshcode-webhook-signature.test.mjs:13
HIGH secret-generic-credential tests/project-inbound-webhook-management.test.mjs:39
HIGH secret-generic-credential tests/project-webhook-management.test.mjs:53
HIGH secret-generic-credential tests/webhooks.test.mjs:13
HIGH secret-generic-credential tests/webhooks.test.mjs:21
MEDIUM js-open-redirect app/dashboard/[[...tab]]/page.tsx:65
MEDIUM sql-template-interpolation app/dashboard/[[...tab]]/page.tsx:1292
MEDIUM js-open-redirect app/signup/page.tsx:38
MEDIUM js-unescaped-html-sink components/Tenant.tsx:56
MEDIUM sql-template-interpolation lib/db.ts:1042
MEDIUM insecure-temp-file public/install.sh:67

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 3, 2026 16:29
@ralyodio
ralyodio merged commit 19be25c into master Aug 3, 2026
3 checks passed
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.

1 participant