test(hooks): regression coverage for dev-server-block subshell bypass

Lock in the behavior added by the previous commit. Each new case was
verified to fail before the fix and pass after.

Bypasses now blocked (exit 2):
- \$(npm run dev)              command substitution
- \`npm run dev\`              backtick substitution
- echo \$(npm run dev)         substitution inside an argument
- (npm run dev)               plain subshell group
- \$(echo a; npm run dev)      substitution containing a sequenced segment
- (pnpm dev)                  plain subshell group, alt package manager

Allow cases — explicitly proven NOT to regress so the fix doesn't
over-block legitimate uses:
- (tmux new-session -d -s dev "npm run dev")   tmux launcher inside ()
- git commit -m '(npm run dev)'                literal in single quotes
- echo "(npm run dev)"                         literal in double quotes
  (bash does NOT subshell () inside double quotes)
- git commit -m '\$(npm run dev) fix'          literal in single quotes

Single- and double-quote allow cases are important: they distinguish a
real subshell construct from one that's just text inside a string,
which is what `extractSubshellGroups` / `extractCommandSubstitutions`
quote-awareness is for.
This commit is contained in:
Jamkris
2026-05-14 11:22:44 +09:00
parent a7e51e8046
commit 4f4654bf21

View File

@@ -89,6 +89,61 @@ function runTests() {
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
}) ? passed++ : failed++);
// --- Subshell bypass regression (issue: dev server slipped past via $(), ``, ()) ---
if (!isWindows) {
(test('blocks $(npm run dev) — command substitution', () => {
const result = runScript('$(npm run dev)');
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
assert.ok(result.stderr.includes('BLOCKED'), 'expected BLOCKED in stderr');
}) ? passed++ : failed++);
(test('blocks `npm run dev` — backtick substitution', () => {
const result = runScript('`npm run dev`');
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
}) ? passed++ : failed++);
(test('blocks echo $(npm run dev) — substitution nested in argument', () => {
const result = runScript('echo $(npm run dev)');
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
}) ? passed++ : failed++);
(test('blocks (npm run dev) — plain subshell group', () => {
const result = runScript('(npm run dev)');
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
}) ? passed++ : failed++);
(test('blocks $(echo a; npm run dev) — substitution with sequenced segments', () => {
const result = runScript('$(echo a; npm run dev)');
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
}) ? passed++ : failed++);
(test('blocks (pnpm dev) — plain subshell group with pnpm', () => {
const result = runScript('(pnpm dev)');
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
}) ? passed++ : failed++);
(test('allows tmux launcher inside subshell wrapping (exit code 0)', () => {
const result = runScript('(tmux new-session -d -s dev "npm run dev")');
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
}) ? passed++ : failed++);
(test('allows single-quoted "(npm run dev)" — literal string, not a subshell', () => {
const result = runScript("git commit -m '(npm run dev)'");
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
}) ? passed++ : failed++);
(test('allows double-quoted "(npm run dev)" — literal in double quotes (bash does not subshell)', () => {
const result = runScript('echo "(npm run dev)"');
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
}) ? passed++ : failed++);
(test("allows single-quoted '$(npm run dev)' — literal string, no substitution", () => {
const result = runScript("git commit -m '$(npm run dev) fix'");
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
}) ? passed++ : failed++);
}
// --- Edge cases ---
(test('empty/invalid input passes through (exit code 0)', () => {