diff --git a/hooks/ponytail-activate.js b/hooks/ponytail-activate.js index 059d219..25f4e48 100644 --- a/hooks/ponytail-activate.js +++ b/hooks/ponytail-activate.js @@ -8,7 +8,7 @@ const fs = require('fs'); const path = require('path'); -const { getDefaultMode, getClaudeDir } = require('./ponytail-config'); +const { getDefaultMode, getClaudeDir, isShellSafe } = require('./ponytail-config'); const { getPonytailInstructions } = require('./ponytail-instructions'); const { clearMode, @@ -57,17 +57,28 @@ if (!isCodex && !isCopilot) try { const isWindows = process.platform === 'win32'; const scriptName = isWindows ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh'; const scriptPath = path.join(__dirname, scriptName); - const command = isWindows - ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` - : `bash "${scriptPath}"`; - const statusLineSnippet = - '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; - output += "\n\n" + - "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " + - "(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " + - "To enable, add this to ~/.claude/settings.json: " + - statusLineSnippet + " " + - "Proactively offer to set this up for the user on first interaction."; + if (isShellSafe(scriptPath)) { + const command = isWindows + ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` + : `bash "${scriptPath}"`; + const statusLineSnippet = + '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; + output += "\n\n" + + "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " + + "(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " + + "To enable, add this to ~/.claude/settings.json: " + + statusLineSnippet + " " + + "Proactively offer to set this up for the user on first interaction."; + } else { + // ponytail: install path has shell metacharacters — don't embed it in a + // command snippet; have the agent wire it up by hand instead. + output += "\n\n" + + "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " + + "Its install path contains characters unsafe to embed in a shell command, so configure it manually: " + + "add a statusLine command of type \"command\" that runs " + scriptName + + " from the plugin's hooks directory to ~/.claude/settings.json, quoting/escaping the path for your shell. " + + "Proactively offer to set this up for the user on first interaction."; + } } } catch (e) { // Silent fail — don't block session start over statusline detection diff --git a/hooks/ponytail-config.js b/hooks/ponytail-config.js index a96d6d0..86677b1 100644 --- a/hooks/ponytail-config.js +++ b/hooks/ponytail-config.js @@ -42,6 +42,15 @@ function isDeactivationCommand(text) { return t === 'stop ponytail' || t === 'normal mode'; } +// ponytail: only embed the plugin install path in a statusline shell command when +// it's made of ordinary path characters. An allowlist beats escaping every shell's +// metacharacters; a hostile clone path (quotes, &, $, backtick, ;, etc.) falls back +// to manual setup instead. Allows : \ / for normal Windows and POSIX paths. Full +// per-shell escaper only if a real need appears. +function isShellSafe(p) { + return typeof p === 'string' && /^[A-Za-z0-9 _.\-:/\\~]+$/.test(p); +} + function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { return path.join(process.env.XDG_CONFIG_HOME, 'ponytail'); @@ -104,6 +113,7 @@ module.exports = { getConfigDir, getConfigPath, getClaudeDir, + isShellSafe, normalizeMode, normalizeConfigMode, normalizePersistedMode, diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 22580e8..d17e65f 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -8,6 +8,16 @@ const { spawnSync } = require('child_process'); const root = path.join(__dirname, '..'); +// isShellSafe gates the statusline setup snippet (issue #200): ordinary install +// paths pass, paths carrying shell metacharacters are rejected so they never get +// embedded in a shell command. +const { isShellSafe } = require('../hooks/ponytail-config'); +assert.equal(isShellSafe('C:\\Users\\x\\.claude\\plugins\\ponytail\\hooks\\ponytail-statusline.ps1'), true); +assert.equal(isShellSafe('/home/u/.claude/plugins/ponytail/hooks/ponytail-statusline.sh'), true); +assert.equal(isShellSafe('/tmp/a"&calc.exe&"/x.sh'), false); +assert.equal(isShellSafe('/tmp/$(calc)/x.sh'), false); +assert.equal(isShellSafe('/tmp/a;rm -rf/x.sh'), false); + function run(script, env, input = '') { return spawnSync(process.execPath, [path.join(root, 'hooks', script)], { env: { ...process.env, ...env },