fix: don't embed shell-unsafe install paths in statusline setup nudge (#224)

The SessionStart nudge built a statusLine command by interpolating the
plugin's __dirname path into a double-quoted shell string. A clone path
containing shell metacharacters (quotes, &, $, backtick, ;) could break
out when the suggested command later runs via the statusline shell.

Low severity in practice: the path is the install location, so triggering
it requires installing into a maliciously-named directory, i.e. the
attacker already controls the filesystem. Hardening it anyway.

Gate the snippet behind isShellSafe() (allowlist of ordinary path chars,
allowing : \ / for normal Windows and POSIX paths). Unsafe paths fall
back to a manual-setup instruction instead of an embeddable command. An
allowlist beats a per-shell escaper, which is its own edge-case bug farm.

Refs #200
This commit is contained in:
DietrichGebert
2026-06-21 01:58:21 +02:00
committed by GitHub
parent 5eb1fd8b76
commit 215777d835
3 changed files with 43 additions and 12 deletions
+23 -12
View File
@@ -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
+10
View File
@@ -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,