Fix #476: stop using JSON.stringify/double quotes as shell quoting in four exec sites (#533)

* Fix: use argv exec and single-quote escaping for the four #476 shell-injection sites

JSON.stringify and raw double-quote interpolation were used as shell quoting,
but /bin/sh still expands $(...), backticks, and ${} inside double quotes.

- is-generated.mjs / live.mjs runScript: switch execSync string commands to
  execFileSync argv form, which never invokes a shell. Closes the remote path
  where a source file named `$(...)` executes during the live-mode walk.
- skills.mjs hook command + hook-lib.mjs ignore-value suggestion: values that
  must stay shell strings now use POSIX single-quote escaping instead of
  JSON/double quotes. The doctor's hook-token parser learns the single-quoted
  absolute form so it keeps verifying user-level installs.

Adds regression tests for the single-quoted absolute hook form and the
single-quoted ignore-value suggestion. Verified end to end in a browser through
a real live-mode wrap walk against a hostile-named source file.

Prepared with AI assistance (Cursor) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Test: lock in POSIX single-quoting for a $(...) absolute install path (#476)

Follow-up from security review: prove an install path embedding $(...) is
single-quoted in the written hook manifest, not double-quoted.

Prepared with AI assistance (Cursor) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: quote ignore-command args per platform so Windows cmd.exe keeps spaces (#533)

Greptile flagged that switching quoteCommandArg to POSIX single quotes fixed
$(...) injection on /bin/sh but regressed Windows cmd.exe, where single quotes
are literal, so a --file path containing spaces was split and the ignore scope
was stored malformed.

The suggested command runs on the same machine the hook fired on, so branch on
process.platform (the pattern skills.mjs already uses): single-quote on POSIX
for the #476 fix, and keep the original double-quote escaping on Windows so
that path's behavior is unchanged. Adds a regression test asserting both forms.

Prepared with AI assistance (Cursor) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Test: prove the POSIX hook guard is inert under /bin/sh and Windows keeps double quotes (#533)

Greptile's probe could not reach the generated manifest, leaving the hook
command contract unverified. Convert that into committed proof:

- POSIX: install with a $(touch pwned) absolute path, then actually execute the
  generated guard under /bin/sh from a clean cwd and assert no marker file
  appears and the guard exits 0 (single-quoted substitution stays inert).
- Windows: drive copyProviderHooks as win32 in-process and assert the command
  keeps the double-quoted absolute path (usable when the install path has
  spaces; $(...) is inert on cmd.exe anyway).

Test-only; source quoting is unchanged.

Prepared with AI assistance (Cursor) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-08-08 18:43:17 -07:00
committed by GitHub
co-authored by Cursor
parent 4596f3183c
commit 490dcfd678
8 changed files with 182 additions and 14 deletions
+21 -5
View File
@@ -1365,17 +1365,28 @@ function hookScriptPathForProvider(skillRoot, provider) {
// with single quotes for the inner string literals. // with single quotes for the inner string literals.
const WIN32_HOOK_GUARD_SCRIPT = "const p=process.argv[1];const f=require('fs');if(f.existsSync(p)){const r=require('child_process').spawnSync(process.execPath,[p],{stdio:'inherit'});process.exit(r.status===null?1:r.status);}"; const WIN32_HOOK_GUARD_SCRIPT = "const p=process.argv[1];const f=require('fs');if(f.existsSync(p)){const r=require('child_process').spawnSync(process.execPath,[p],{stdio:'inherit'});process.exit(r.status===null?1:r.status);}";
// POSIX single-quote escaping. JSON.stringify is not shell quoting: inside
// double quotes /bin/sh still expands $(...), backticks, and ${}, and this
// string is baked into a hook manifest the harness re-executes on every edit,
// so an install path embedding $(...) would run it repeatedly (issue #476).
// Windows command forms keep double quotes: cmd.exe treats ' as a literal
// character and performs no command substitution.
function shSingleQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function windowsHookCommand(quotedPath) { function windowsHookCommand(quotedPath) {
return `if exist ${quotedPath} (node ${quotedPath} & exit /b)`; return `if exist ${quotedPath} (node ${quotedPath} & exit /b)`;
} }
// `quotedPath` carries one pre-quoted form per target shell: { posix, win32 }.
function guardHookCommand(quotedPath, provider) { function guardHookCommand(quotedPath, provider) {
// `.agents` (Codex) keeps the POSIX form unconditionally: its Windows // `.agents` (Codex) keeps the POSIX form unconditionally: its Windows
// consumers read the commandWindows sibling instead. // consumers read the commandWindows sibling instead.
if (provider !== '.agents' && process.platform === 'win32') { if (provider !== '.agents' && process.platform === 'win32') {
return `node -e "${WIN32_HOOK_GUARD_SCRIPT}" ${quotedPath}`; return `node -e "${WIN32_HOOK_GUARD_SCRIPT}" ${quotedPath.win32}`;
} }
return `[ ! -f ${quotedPath} ] || node ${quotedPath}`; return `[ ! -f ${quotedPath.posix} ] || node ${quotedPath.posix}`;
} }
// Transform bundled hook commands for the actual install target: // Transform bundled hook commands for the actual install target:
@@ -1398,9 +1409,14 @@ function rewriteHookCommandsForSkillRoot(value, provider, { skillRoot, absolute
// Project-scope installs derive the provider's own project-relative path // Project-scope installs derive the provider's own project-relative path
// rather than trusting the bundle token, which for Codex points at // rather than trusting the bundle token, which for Codex points at
// `.codex/skills/...` while the CLI installs the skill at `.agents/skills/`. // `.codex/skills/...` while the CLI installs the skill at `.agents/skills/`.
// The absolute path comes from the install root (project dir or $HOME), so
// its POSIX form gets real single-quote escaping (issue #476). The relative
// form is a per-provider constant and stays double-quoted, because Claude's
// ${CLAUDE_PROJECT_DIR} token must keep expanding at hook time.
const relPath = hookScriptRelPathForProvider(provider);
const quotedPath = absolute const quotedPath = absolute
? JSON.stringify(hookScript) ? { posix: shSingleQuote(hookScript), win32: JSON.stringify(hookScript) }
: JSON.stringify(hookScriptRelPathForProvider(provider)); : { posix: JSON.stringify(relPath), win32: JSON.stringify(relPath) };
if (typeof value === 'string') { if (typeof value === 'string') {
if (!valueHasImpeccableHookMarker(value)) return value; if (!valueHasImpeccableHookMarker(value)) return value;
@@ -1415,7 +1431,7 @@ function rewriteHookCommandsForSkillRoot(value, provider, { skillRoot, absolute
next[key] = rewriteHookCommandsForSkillRoot(child, provider, { skillRoot, absolute }); next[key] = rewriteHookCommandsForSkillRoot(child, provider, { skillRoot, absolute });
} }
if (provider === '.agents' && typeof value.command === 'string' && valueHasImpeccableHookMarker(value.command)) { if (provider === '.agents' && typeof value.command === 'string' && valueHasImpeccableHookMarker(value.command)) {
next.commandWindows = windowsHookCommand(quotedPath); next.commandWindows = windowsHookCommand(quotedPath.win32);
} }
return next; return next;
} }
+13 -1
View File
@@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) {
function quoteCommandArg(value) { function quoteCommandArg(value) {
const text = String(value || '').trim(); const text = String(value || '').trim();
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; // The suggestion is meant to be run on this same machine, so quote for its
// shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside
// double quotes, and these values come from scanned file content (a
// font-family name) or a file path, so untrusted input must be
// single-quoted (issue #476). Windows cmd.exe performs no such command
// substitution, but it treats a single quote as a literal character rather
// than a grouping delimiter, so a value or path containing spaces has to
// stay double-quoted there (Greptile #533). Keep the pre-existing
// double-quote escaping on Windows so that path's behavior is unchanged.
if (process.platform === 'win32') {
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return `'${text.replace(/'/g, `'\\''`)}'`;
} }
function relativize(filePath, cwd) { function relativize(filePath, cwd) {
+5 -2
View File
@@ -13,7 +13,7 @@
* within the first ~300 characters — catches non-git projects. * within the first ~300 characters — catches non-git projects.
*/ */
import { execSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) {
function isGitIgnored(absPath, cwd) { function isGitIgnored(absPath, cwd) {
try { try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { // argv form, never a shell: this runs on every file the live-mode source
// walk reaches, so a hostile filename embedding $(...) or backticks must
// not be interpretable (issue #476). JSON.stringify is not shell quoting.
execFileSync('git', ['check-ignore', '--quiet', absPath], {
cwd, cwd,
stdio: 'ignore', stdio: 'ignore',
}); });
+8 -1
View File
@@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
// * bundle-relative: node ".agents/.../hook.mjs" // * bundle-relative: node ".agents/.../hook.mjs"
// * legacy unquoted: node .claude/.../hook.mjs // * legacy unquoted: node .claude/.../hook.mjs
// * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
// * absolute: node "/Users/.../hook.mjs" (user-level installs) // * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since
// the shell-injection fix; older installs double-quote)
// * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
// A quoted path wins; the guard's two occurrences are identical, so the first // A quoted path wins; the guard's two occurrences are identical, so the first
// quoted match is the path. Otherwise fall back to the whitespace/metachar- // quoted match is the path. Otherwise fall back to the whitespace/metachar-
@@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) {
if (!HOOK_MARKER.test(str)) return null; if (!HOOK_MARKER.test(str)) return null;
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
if (quoted) return quoted[1]; if (quoted) return quoted[1];
// A path containing an apostrophe serializes as '\'' inside single quotes;
// no regex reassembles that, and the bare fallback would misread a fragment
// of it, so return null: the caller never asserts on a path it can't parse.
if (str.includes("'\\''")) return null;
const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/);
if (singleQuoted) return singleQuoted[1];
const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
return bare ? bare[1] : null; return bare ? bare[1] : null;
} }
+10 -4
View File
@@ -17,7 +17,7 @@
* node live.mjs --help * node live.mjs --help
*/ */
import { execSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -316,11 +316,17 @@ function globToRegex(pattern) {
function runScript(name, args, options = {}) { function runScript(name, args, options = {}) {
const scriptPath = path.join(__dirname, name); const scriptPath = path.join(__dirname, name);
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
try { try {
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); // argv form, never a shell: string interpolation into double quotes would
// let a `"` or `$(...)` in any future caller's arg escape into the shell
// (issue #476).
return execFileSync(process.execPath, [scriptPath, ...args], {
encoding: 'utf-8',
cwd: options.cwd || process.cwd(),
timeout: 15_000,
});
} catch (err) { } catch (err) {
// execSync throws on non-zero exit; return stdout if any // execFileSync throws on non-zero exit; return stdout if any
return err.stdout || err.message || ''; return err.stdout || err.message || '';
} }
} }
+23
View File
@@ -486,6 +486,29 @@ describe('checkHookInstallation', () => {
); );
}); });
it('handles the #476 single-quoted absolute form (user-level installs)', () => {
// The shell-injection fix single-quotes the absolute POSIX path instead of
// JSON.stringify. The doctor's token parser must read the single-quoted
// form too, or it silently stops verifying every user-level install.
const abs = path.join(scratch, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
const p = `'${abs}'`;
const guarded = `[ ! -f ${p} ] || node ${p}`;
write('.claude/settings.json', JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: guarded }] }] },
}));
// absolute path missing → flagged
assert.deepEqual(
ids(checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' })),
['hook-script-missing'],
);
// present → quiet
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
[],
);
});
it('never reports missing for the GitHub $(git rev-parse) form', () => { it('never reports missing for the GitHub $(git rev-parse) form', () => {
// Command substitution is not statically resolvable; a doctor must not // Command substitution is not statically resolvable; a doctor must not
// assert a negative it cannot verify. // assert a negative it cannot verify.
+36
View File
@@ -1142,6 +1142,42 @@ describe('renderTemplate()', () => {
assert.match(text, /\/impeccable hooks ignore-value bounce-easing bounce-ball --shared/); assert.match(text, /\/impeccable hooks ignore-value bounce-easing bounce-ball --shared/);
}); });
it('single-quotes a hostile font value so the suggestion cannot inject a shell command (#476)', () => {
// The suggested command comes straight from scanned file content. A
// double-quoted arg would leave $(...) live for whoever runs the
// suggestion; single quotes neutralize it.
const text = renderTemplate(
[finding('overused-font', 1, {
name: 'Overused font',
snippet: 'body { font-family: "$(touch pwned)", sans-serif; }',
})],
'/x/fonts.css', DEFAULT_CONFIG, { cwd: '/x' }
);
assert.match(text, /ignore-value overused-font '\$\(touch pwned\)' --shared/);
assert.doesNotMatch(text, /ignore-value overused-font "\$\(touch pwned\)"/);
});
it('quotes the --file path per platform: single quotes on POSIX, double quotes on Windows (#533)', () => {
// The suggested command is run on the same machine the hook fired on.
// POSIX needs single quotes so $(...) in a filename cannot execute; Windows
// cmd.exe treats single quotes as literal, so a path with spaces must stay
// double-quoted or the ignore scope is split at the space.
const original = process.platform;
const renderFor = (platform) => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
try {
return renderTemplate(
[finding('side-tab', 1, { name: 'Side tab' })],
'/x/My Components/Card.tsx', DEFAULT_CONFIG, { cwd: '/x' }
);
} finally {
Object.defineProperty(process, 'platform', { value: original, configurable: true });
}
};
assert.match(renderFor('linux'), /--file 'My Components\/Card\.tsx'/);
assert.match(renderFor('win32'), /--file "My Components\/Card\.tsx"/);
});
it('drops the L<line> prefix when line is 0', () => { it('drops the L<line> prefix when line is 0', () => {
const text = renderTemplate( const text = renderTemplate(
[finding('side-tab', 0, { name: 'X' })], [finding('side-tab', 0, { name: 'X' })],
+66 -1
View File
@@ -10,7 +10,7 @@
* gracefully when impeccable.style is unreachable. * gracefully when impeccable.style is unreachable.
*/ */
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { execSync } from 'child_process'; import { execSync, execFileSync } from 'child_process';
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync } from 'fs'; import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
@@ -1728,6 +1728,71 @@ describe('copyProviderHooks: hook command path resolution (#399)', () => {
rmSync(tmp, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true });
rmSync(skillHome, { recursive: true, force: true }); rmSync(skillHome, { recursive: true, force: true });
}); });
test('single-quotes an absolute install path that embeds $(...), and the guard is inert under /bin/sh (#476)', () => {
// A hook command is re-executed by the harness on every edit. JSON.stringify
// is not shell quoting: an install path containing $(...) inside double
// quotes would run on each fire. The absolute POSIX form must be
// single-quoted so the substitution stays inert.
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-split-'));
const skillHome = mkdtempSync(join(tmpdir(), 'imp-hook-$(touch pwned)-'));
const bundleDir = createProjectDirBundle(tmp);
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: skillHome });
const raw = readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8');
// The path appears single-quoted, never double-quoted (which would leave
// the substitution live for /bin/sh).
expect(raw).toContain(`'${skillHome}`);
expect(raw).not.toContain(`"${skillHome}`);
const commands = claudeHookCommands(join(tmp, '.claude', 'settings.local.json'));
expect(commands.length).toBeGreaterThan(0);
// End-to-end: actually run each generated guard under /bin/sh from a clean
// cwd. The hook script does not exist (skillHome is empty), so `[ ! -f ... ]`
// short-circuits and node never runs — and crucially the single-quoted
// $(touch pwned) must not execute. Prove it: no `pwned` file appears and the
// guard exits 0.
if (process.platform !== 'win32') {
const runCwd = mkdtempSync(join(tmpdir(), 'imp-hook-run-'));
for (const command of commands) {
expect(command).toContain('[ ! -f ');
expect(command).not.toMatch(/"[^"]*\$\(touch pwned\)/);
execFileSync('/bin/sh', ['-c', command], { cwd: runCwd, stdio: 'ignore' });
}
expect(existsSync(join(runCwd, 'pwned'))).toBe(false);
rmSync(runCwd, { recursive: true, force: true });
}
rmSync(tmp, { recursive: true, force: true });
rmSync(skillHome, { recursive: true, force: true });
});
test('the Windows hook form keeps a usable double-quoted absolute path (#533)', () => {
// cmd.exe does no $(...) substitution but treats single quotes as literal,
// so the Windows command form must keep the absolute path double-quoted or
// a space in the install path would split the argument. copyProviderHooks
// branches on process.platform, so drive it as win32 in-process.
const original = process.platform;
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-win-'));
const skillHome = mkdtempSync(join(tmpdir(), 'imp-hook-win-home-'));
const bundleDir = createProjectDirBundle(tmp);
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: skillHome });
} finally {
Object.defineProperty(process, 'platform', { value: original, configurable: true });
}
const absolute = join(skillHome, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
for (const command of claudeHookCommands(join(tmp, '.claude', 'settings.local.json'))) {
// Windows guard shape (node -e wrapper) with the absolute path double-quoted.
expect(command).toContain(`"${absolute}"`);
expect(command).not.toContain(`'${absolute}`);
expect(command).toContain('node -e');
}
rmSync(tmp, { recursive: true, force: true });
rmSync(skillHome, { recursive: true, force: true });
});
}); });
// ─── Update scope resolution (issue #399, part 2) ──────────────────────────── // ─── Update scope resolution (issue #399, part 2) ────────────────────────────