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
123 lines
3.9 KiB
JavaScript
123 lines
3.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// ponytail — shared configuration resolver
|
|
//
|
|
// Resolution order for default mode:
|
|
// 1. PONYTAIL_DEFAULT_MODE environment variable
|
|
// 2. Config file defaultMode field:
|
|
// - $XDG_CONFIG_HOME/ponytail/config.json (any platform, if set)
|
|
// - ~/.config/ponytail/config.json (macOS / Linux fallback)
|
|
// - %APPDATA%\ponytail\config.json (Windows fallback)
|
|
// 3. 'full'
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
const DEFAULT_MODE = 'full';
|
|
const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
|
|
const RUNTIME_MODES = ['off', 'lite', 'full', 'ultra'];
|
|
|
|
function normalizeMode(mode) {
|
|
if (typeof mode !== 'string') return null;
|
|
const normalized = mode.trim().toLowerCase();
|
|
return RUNTIME_MODES.includes(normalized) ? normalized : null;
|
|
}
|
|
|
|
function normalizeConfigMode(mode) {
|
|
if (typeof mode !== 'string') return null;
|
|
const normalized = mode.trim().toLowerCase();
|
|
return VALID_MODES.includes(normalized) ? normalized : null;
|
|
}
|
|
|
|
function normalizePersistedMode(mode) {
|
|
return normalizeMode(mode) || normalizeConfigMode(mode);
|
|
}
|
|
|
|
// "stop ponytail" / "normal mode" turn ponytail off, but only as a standalone
|
|
// command. Matching the phrase anywhere in the message turned it off mid-task
|
|
// for ordinary requests like "add a normal mode toggle" — so require the whole
|
|
// message to be the command, ignoring case and trailing punctuation.
|
|
function isDeactivationCommand(text) {
|
|
const t = String(text || '').trim().toLowerCase().replace(/[.!?\s]+$/, '');
|
|
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');
|
|
}
|
|
if (process.platform === 'win32') {
|
|
return path.join(
|
|
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
|
'ponytail'
|
|
);
|
|
}
|
|
return path.join(os.homedir(), '.config', 'ponytail');
|
|
}
|
|
|
|
function getConfigPath() {
|
|
return path.join(getConfigDir(), 'config.json');
|
|
}
|
|
|
|
function getClaudeDir() {
|
|
// ponytail: CLAUDE_CONFIG_DIR overrides ~/.claude, matching Claude Code.
|
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
}
|
|
|
|
function getDefaultMode() {
|
|
// 1. Environment variable (highest priority)
|
|
const envMode = process.env.PONYTAIL_DEFAULT_MODE;
|
|
if (envMode && VALID_MODES.includes(envMode.toLowerCase())) {
|
|
return envMode.toLowerCase();
|
|
}
|
|
|
|
// 2. Config file
|
|
try {
|
|
const configPath = getConfigPath();
|
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) {
|
|
return config.defaultMode.toLowerCase();
|
|
}
|
|
} catch (e) {
|
|
// Config file doesn't exist or is invalid — fall through
|
|
}
|
|
|
|
// 3. Default
|
|
return DEFAULT_MODE;
|
|
}
|
|
|
|
function writeDefaultMode(mode) {
|
|
const normalized = normalizeConfigMode(mode);
|
|
if (!normalized) return null;
|
|
|
|
const configPath = getConfigPath();
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(configPath, JSON.stringify({ defaultMode: normalized }, null, 2), 'utf8');
|
|
return normalized;
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_MODE,
|
|
VALID_MODES,
|
|
RUNTIME_MODES,
|
|
getDefaultMode,
|
|
getConfigDir,
|
|
getConfigPath,
|
|
getClaudeDir,
|
|
isShellSafe,
|
|
normalizeMode,
|
|
normalizeConfigMode,
|
|
normalizePersistedMode,
|
|
isDeactivationCommand,
|
|
writeDefaultMode,
|
|
};
|