Skip to content

feat(plugin): add mempalace-remote — SSH-proxied palace for remote clients#1190

Open
messelink wants to merge 7 commits intoMemPalace:developfrom
messelink:feat/remote-plugin
Open

feat(plugin): add mempalace-remote — SSH-proxied palace for remote clients#1190
messelink wants to merge 7 commits intoMemPalace:developfrom
messelink:feat/remote-plugin

Conversation

@messelink
Copy link
Copy Markdown
Contributor

@messelink messelink commented Apr 25, 2026

Summary

Adds a sibling plugin, mempalace-remote, that mirrors the existing mempalace plugin's MCP tools and auto-save hooks but proxies them to a central host over SSH instead of running locally. Lets a fleet of client machines (VPS, second laptop, dev container, etc.) share one canonical palace without each running its own mempalace install.

The marketplace plugins array gains a second entry, so the same MemPalace/mempalace repo serves both plugins. Existing users are unaffected — claude /plugin install mempalace@mempalace still installs only the local-install variant.

Why

I run mempalace on a home server and use Claude Code from several other machines. The hand-rolled approach was a pair of ~/.local/bin/{mempalace,mempalace-mcp} shell wrappers that ssh ... to the central install. That works for the MCP server but skips the auto-save Stop and PreCompact hooks entirely, since the upstream plugin's hook scripts call the local mempalace CLI directly. Packaging the wrappers + hook proxies as a real plugin makes the central-palace pattern install-and-go for anyone running the same setup.

Discussed (lightly) in #1049 and adjacent threads about non-local mempalace deployments.

Layout

Mirrors the existing .claude-plugin/ structure:

.claude-plugin-remote/
├── plugin.json                       # plugin manifest
├── .mcp.json                         # ${CLAUDE_PLUGIN_ROOT}/bin/mempalace-mcp-ssh.sh
├── README.md
├── bin/
│   └── mempalace-mcp-ssh.sh          # thin SSH wrapper for the MCP server
└── hooks/
    ├── hooks.json                    # registers Stop + PreCompact
    ├── mempal-stop-hook.sh           # ssh-pipe stdin to remote `mempalace hook run`
    └── mempal-precompact-hook.sh     # same for precompact

.claude-plugin/marketplace.json gains a single appended entry pointing at ./.claude-plugin-remote.

Configuration

Env var Required Default Description
MEMPALACE_REMOTE_HOST yes SSH target — alias from ~/.ssh/config or fully qualified hostname
MEMPALACE_REMOTE_BIN no mempalace Path to the mempalace CLI on the remote (used by Stop / PreCompact hooks)
MEMPALACE_REMOTE_MCP_BIN no mempalace-mcp Path to the mempalace-mcp server on the remote (used by the MCP client)

The _BIN overrides cover the SSH non-interactive PATH gotcha where ~/.local/bin isn't on PATH for ssh host command shells. Bare defaults work for system-wide installs.

How it works

  • MCP serverclaude spawns ${CLAUDE_PLUGIN_ROOT}/bin/mempalace-mcp-ssh.sh, which validates the env vars and execs ssh -- $HOST mempalace-mcp. Stdio JSON-RPC rides the SSH channel transparently — the MCP client doesn't know it's remote.
  • Stop / PreCompact hooks — fire on the client, validate env vars, pipe Claude Code's hook JSON to ssh -- $HOST mempalace hook run --hook {stop,precompact} --harness claude-code. The remote mempalace CLI handles all the actual logic; the bash wrapper is intentionally thin.

Coexistence

Don't enable both mempalace and mempalace-remote on the same machine — both register an MCP server named mempalace and the second one to load shadows the first. The README calls this out.

Tested

End-to-end test on a real two-machine setup (VPS client → home-server central palace):

  • claude /plugin marketplace addclaude /plugin install mempalace-remote@mempalace → set env vars → restart Claude Code → working
  • mempalace_status MCP call returned real wing/room counts from the central palace ✓
  • Stop hook fired and landed in ~/.mempalace/hook_state/hook.log on the central host with the correct client session ID ✓
  • PreCompact hook (/compact) fired and landed in the same log ✓
  • Latency: sub-300ms per MCP call without SSH ControlMaster; ControlMaster optional, documented in README.

claude plugin validate passes on both .claude-plugin/plugin.json and .claude-plugin-remote/plugin.json.

Security

Two issues caught in review and fixed:

  • SSH option-parsing of hostMEMPALACE_REMOTE_HOST is now passed after -- so a value starting with - can't be interpreted as ssh options (e.g. -oProxyCommand=... local code execution). Caught by Qodo review.
  • Remote command injection via the _BIN env varsMEMPALACE_REMOTE_BIN and MEMPALACE_REMOTE_MCP_BIN are validated against an allow-list regex (^[A-Za-z0-9_./-]+$) before being concatenated into the SSH remote command string, blocking the BIN='...; rm -rf ~ #' class. The MCP server config goes through the bin/mempalace-mcp-ssh.sh wrapper to apply the same defense the JSON config can't express directly.

Notes for review

  • New env vars use the MEMPALACE_REMOTE_* namespace; no clash with existing MEMPAL_* env vars used by the local plugin.
  • The author field on the new marketplace entry is messelink (mine). Happy to change to whatever attribution convention you prefer.
  • Drift watching: if upstream changes .claude-plugin/hooks/hooks.json (e.g. adds SessionStart), the remote variant should mirror the addition. Same protocol, same CLI signature. Adjacent: Consolidate hooks/mempal_*_hook.sh into thin wrappers delegating to mempalace hook run #1069's proposed consolidation of the legacy hooks/mempal_*_hook.sh scripts uses the same thin-wrapper-to-mempalace hook run pattern this PR follows.
  • Codex variant: not included here — same pattern would work for .codex-plugin-remote/ if there's interest.

messelink and others added 3 commits April 25, 2026 02:54
…ients

Adds a sibling plugin that mirrors the mempalace plugin's MCP tools and
auto-save hooks, but proxies them to a central host over SSH instead of
running locally. Lets a fleet of client machines share one canonical
palace without each running their own mempalace install.

Plugin layout matches the existing .claude-plugin/ structure:

  .claude-plugin-remote/
    plugin.json
    .mcp.json                          ssh ${HOST} mempalace-mcp
    hooks/hooks.json                   registers Stop + PreCompact
    hooks/mempal-stop-hook.sh          ssh-pipe stdin to remote `mempalace hook run`
    hooks/mempal-precompact-hook.sh    same for precompact

The marketplace.json `plugins` array gains a second entry so the existing
`MemPalace/mempalace` marketplace serves both plugins from the same repo.
Existing users are unaffected — `claude /plugin install mempalace@mempalace`
still installs only the local-install variant.

Single required env var: MEMPALACE_REMOTE_HOST (an alias from ~/.ssh/config
or a fully qualified hostname). README documents passwordless-key prereq,
ControlMaster recommendation, and the SSH non-interactive PATH gotcha.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Lets users point at full paths on the central host without symlinking
into /usr/local/bin or fixing the SSH non-interactive PATH. Both vars
are optional and default to bare 'mempalace' / 'mempalace-mcp' for
hosts where those are on PATH already.

Documented as the recommended fix for the PATH gotcha section.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Replace 'raindance' (my central host alias) with the generic 'palace-host'
  in the env var example and ControlMaster snippet.
- 'Strongly recommended: SSH ControlMaster' -> 'Optional' — sub-300ms per
  call without it on a real two-machine test, only worth setting up under
  heavy tool use.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@Qodo-Free-For-OSS
Copy link
Copy Markdown

Hi, MEMPALACE_REMOTE_HOST is passed as the first positional argument to ssh without an option terminator, so a value starting with "-" will be parsed as ssh options (e.g., ProxyCommand), enabling unintended local command execution under the plugin process. This affects both the MCP server spawn and the Stop/PreCompact hook invocations.

Severity: action required | Category: security

How to fix: Terminate ssh options with --

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

MEMPALACE_REMOTE_HOST is passed to ssh as the destination without terminating option parsing. If it starts with -, OpenSSH will interpret it as options (e.g. -o ProxyCommand=...), which can lead to unexpected local command execution.

Issue Context

This affects both:

  • the MCP server spawn (plugin.json / .mcp.json), and
  • the Stop/PreCompact hook scripts.

Fix Focus Areas

  • .claude-plugin-remote/hooks/mempal-stop-hook.sh[1-4]
  • .claude-plugin-remote/hooks/mempal-precompact-hook.sh[1-4]
  • .claude-plugin-remote/plugin.json[10-15]
  • .claude-plugin-remote/.mcp.json[1-6]

Implementation notes

  • Update shell scripts to ssh -- "${MEMPALACE_REMOTE_HOST:?…}" ....
  • Update JSON args to include "--" before the host: "args": ["--", "${MEMPALACE_REMOTE_HOST}", ...].
  • (Optional hardening) Reject host values beginning with - explicitly before invoking ssh.

We noticed a couple of other issues in this PR as well - happy to share if helpful.


Spotted by Qodo code review - free for open-source projects.

Per Qodo review on MemPalace#1190: passing MEMPALACE_REMOTE_HOST as the first
positional argument to ssh without an option terminator means a value
starting with '-' would be parsed as ssh options (e.g.
-oProxyCommand=...), enabling local command execution.

Adds -- before the host in both the .mcp.json args array and both hook
scripts. Smoke test still returns {} cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@messelink
Copy link
Copy Markdown
Contributor Author

Thanks @qodo-ai-reviewer — good catch. Fixed in 3844bbe: added -- before the host in both .mcp.json (and the equivalent block in plugin.json) and both hook scripts.

Yes please do share the other issues you spotted.

Pre-existing concern, surfaced by the same Qodo review on MemPalace#1190 that
caught the host option-parsing issue: MEMPALACE_REMOTE_BIN and
MEMPALACE_REMOTE_MCP_BIN are concatenated into the SSH remote command
string, which the central host's shell then parses. A value like
'mempalace; touch /tmp/X #' lands as 'sh -c "mempalace; touch /tmp/X #
hook run ..."' on the remote — RCE on the central host as the SSH user.

Fix:

- Hook scripts validate HOST and BIN against ^[A-Za-z0-9_./@-]+$ and
  ^[A-Za-z0-9_./-]+$ before invoking ssh; fail loudly with a clear
  message if the env var contains anything outside that set.

- MCP server entry switches from raw 'ssh' + JSON args to a thin wrapper
  bin/mempalace-mcp-ssh.sh that does the same validation. Lets us apply
  defense in JSON config too without trying to do shell-quoting in JSON.
  Both .mcp.json and plugin.json's mcpServers block now point at
  ${CLAUDE_PLUGIN_ROOT}/bin/mempalace-mcp-ssh.sh.

The character classes cover all real paths and hostnames (incl. user@host
in HOST) and reject ;, &, |, $, (, ), <, >, backtick, quote, space, and
shell-glob metas. The '--' option terminator on ssh stays as belt-and-
suspenders against the host-as-option case.

Smoke test: legitimate values still work end-to-end (Stop hook returns
{} cleanly); 'BIN=...; touch /tmp/X #' is rejected before any ssh call.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@messelink
Copy link
Copy Markdown
Contributor Author

Found a related second issue while looking for more — the same env-var injection vector applies to the bin path arguments:

  • MEMPALACE_REMOTE_BIN (in the hook scripts) and MEMPALACE_REMOTE_MCP_BIN (in the MCP server config) are concatenated into the SSH remote command string, which the central host's shell parses. A value like mempalace; touch /tmp/X # becomes sh -c "mempalace; touch /tmp/X # hook run ..." on the remote — RCE on the central host as the SSH user.

Fixed in d6e728b:

  • Hook scripts validate HOST and BIN against allow-list regexes before invoking ssh, fail with a clear message otherwise.
  • MCP server entry now goes through a thin wrapper at bin/mempalace-mcp-ssh.sh that does the same validation, called via ${CLAUDE_PLUGIN_ROOT}/bin/... — lets us apply the same defense in the JSON config without trying to shell-quote inside JSON.
  • The -- option terminator stays as belt-and-suspenders against the host-as-option case from your first finding.

Smoke-tested both: legitimate values still produce {} from the Stop hook end-to-end; injection attempts are rejected before any ssh call.

If you have more findings still queued, please share — happy to keep iterating.

messelink and others added 2 commits April 25, 2026 08:32
Bumps both the plugin manifest and the marketplace entry so installed
clients pick up the security fixes (3844bbe + d6e728b) on the next
'/plugin marketplace update' + '/plugin update' cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The bash wrapper around the MCP server and the bash hook scripts won't
run on bare Windows cmd/PowerShell. Document the requirement explicitly
since this plugin introduces the bash-MCP dependency the local mempalace
plugin doesn't have (it ships a pip-installed mempalace-mcp.exe directly).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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