Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 141 additions & 1 deletion scripts/hooks/pre-bash-dev-server-block.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,140 @@
const MAX_STDIN = 1024 * 1024;
const { splitShellSegments } = require('../lib/shell-split');

const DEV_COMMAND_WORDS = new Set([
'npm',
'pnpm',
'yarn',
'bun',
'npx',
'bash',
'sh',
'zsh',
'fish',
'tmux'
Comment on lines +13 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wrapper commands can still false-positive on quoted text.

Because bash, sh, zsh, fish, and tmux are in DEV_COMMAND_WORDS, Line 165 still runs devPattern on the full raw segment after the wrapper is identified. Cases like bash -lc 'echo "npm run dev"' or tmux display-message "npm run dev" will still be blocked even though they never start a dev server, so the quoted/heredoc false-positive path remains for wrapper commands.

Also applies to: 160-166

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/pre-bash-dev-server-block.js` around lines 13 - 17, When a
wrapper command from DEV_COMMAND_WORDS is detected, we should not run devPattern
against the entire raw segment because quoted arguments (e.g., bash -lc 'echo
"npm run dev"' or tmux display-message "npm run dev") cause false positives;
instead, extract the inner command string passed to the wrapper (strip wrapper
name and common flags like -c, -lc, --, and tmux/display-message argument
wrappers) or skip quoted/heredoc sections, then run devPattern only on that
extracted/unquoted command; update the logic that currently calls devPattern on
the full raw segment so it first detects wrapper commands in DEV_COMMAND_WORDS,
extracts the actual child command, unquotes or removes heredoc content, and only
then applies devPattern to avoid wrapper-induced false positives.

]);
const SKIPPABLE_PREFIX_WORDS = new Set(['env', 'command', 'builtin', 'exec', 'noglob', 'sudo']);
const PREFIX_OPTION_VALUE_WORDS = {
env: new Set(['-u', '-C', '-S', '--unset', '--chdir', '--split-string']),
Comment on lines +20 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

env -S currently creates a bypass.

-S / --split-string hold the command string that env will execute, but this path skips that value entirely. env -S "npm run dev" therefore returns null from getLeadingCommandWord() and never reaches the blocker, which is the opposite of the wrapper support this PR is adding.

💡 Minimal direction
     if (activeWrapper && isOptionToken(token)) {
       if (shouldSkipOptionValue(activeWrapper, token)) {
+        if (activeWrapper === 'env' && (token === '-S' || token === '--split-string')) {
+          const value = readToken(segment, index);
+          return value ? getLeadingCommandWord(value.token) : null;
+        }
         skipNextValue = true;
       }
       continue;
     }

Also applies to: 128-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/pre-bash-dev-server-block.js` around lines 20 - 21, The parsing
currently lets env -S/--split-string bypass because the split-string argument
(the following token) is ignored; update the parser so PREFIX_OPTION_VALUE_WORDS
handling treats '-S' and '--split-string' as options that consume the next token
as the command string and skips that consumed token when computing the leading
command; modify getLeadingCommandWord to recognize and advance past the
split-string value (same change for the other occurrences referenced around
lines 128-133) so env -S "npm run dev" yields "npm" as the leading command.

sudo: new Set([
'-u',
'-g',
'-h',
'-p',
'-r',
'-t',
'-C',
'--user',
'--group',
'--host',
'--prompt',
'--role',
'--type',
'--close-from'
])
};

function readToken(input, startIndex) {
let index = startIndex;
while (index < input.length && /\s/.test(input[index])) index += 1;
if (index >= input.length) return null;

let token = '';
let quote = null;

while (index < input.length) {
const ch = input[index];
if (quote) {
if (ch === quote) {
quote = null;
index += 1;
continue;
}
if (ch === '\\' && quote === '"' && index + 1 < input.length) {
token += input[index + 1];
index += 2;
continue;
}
token += ch;
index += 1;
continue;
}

if (ch === '"' || ch === "'") {
quote = ch;
index += 1;
continue;
}

if (/\s/.test(ch)) break;

if (ch === '\\' && index + 1 < input.length) {
token += input[index + 1];
index += 2;
continue;
}

token += ch;
index += 1;
}

return { token, end: index };
}

function shouldSkipOptionValue(wrapper, optionToken) {
if (!wrapper || !optionToken || optionToken.includes('=')) return false;
const optionSet = PREFIX_OPTION_VALUE_WORDS[wrapper];
if (!optionSet) return false;
return optionSet.has(optionToken);
}

function isOptionToken(token) {
return token.startsWith('-') && token.length > 1;
}

function getLeadingCommandWord(segment) {
let index = 0;
let activeWrapper = null;
let skipNextValue = false;

while (index < segment.length) {
const parsed = readToken(segment, index);
if (!parsed) return null;
index = parsed.end;

const token = parsed.token;
if (!token) continue;

if (skipNextValue) {
skipNextValue = false;
continue;
}

if (token === '--') {
activeWrapper = null;
continue;
}

if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token)) continue;

if (SKIPPABLE_PREFIX_WORDS.has(token)) {
activeWrapper = token;
continue;
}

if (activeWrapper && isOptionToken(token)) {
if (shouldSkipOptionValue(activeWrapper, token)) {
skipNextValue = true;
}
continue;
}

return token;
}

return null;
}

let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
Expand All @@ -23,7 +157,13 @@ process.stdin.on('end', () => {
const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/;

const hasBlockedDev = segments.some(segment => devPattern.test(segment) && !tmuxLauncher.test(segment));
const hasBlockedDev = segments.some(segment => {
const commandWord = getLeadingCommandWord(segment);
if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) {
return false;
}
return devPattern.test(segment) && !tmuxLauncher.test(segment);
});

if (hasBlockedDev) {
console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');
Expand Down
42 changes: 38 additions & 4 deletions tests/integration/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ async function runTests() {
const scriptsDir = path.join(__dirname, '..', '..', 'scripts', 'hooks');
const hooksJsonPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
const devServerBlockerPath = path.join(scriptsDir, 'pre-bash-dev-server-block.js');

// ==========================================
// Input Format Tests
Expand Down Expand Up @@ -253,8 +254,7 @@ async function runTests() {

if (await asyncTest('blocking hooks output BLOCKED message', async () => {
// Test the dev server blocking hook — must send a matching command
const blockingCommand = hooks.hooks.PreToolUse[0].hooks[0].command;
const result = await runHookCommand(blockingCommand, {
const result = await runHookWithInput(devServerBlockerPath, {
tool_input: { command: 'npm run dev' }
});

Expand All @@ -279,8 +279,7 @@ async function runTests() {

if (await asyncTest('blocking hooks exit with code 2', async () => {
// The dev server blocker blocks when a dev server command is detected
const blockingCommand = hooks.hooks.PreToolUse[0].hooks[0].command;
const result = await runHookCommand(blockingCommand, {
const result = await runHookWithInput(devServerBlockerPath, {
tool_input: { command: 'yarn dev' }
});

Expand All @@ -292,6 +291,41 @@ async function runTests() {
}
})) passed++; else failed++;

if (await asyncTest('dev server blocker ignores heredoc prose in non-dev commands', async () => {
const heredocCommand = [
'gh pr create --title "test" --body "$(cat <<\'EOF\'',
'## Test plan',
'',
'- [ ] Run npm run dev to verify the site starts',
'',
'EOF',
')"'
].join('\n');
const result = await runHookWithInput(devServerBlockerPath, {
tool_input: { command: heredocCommand }
});

assert.strictEqual(result.code, 0, 'Non-dev commands with heredoc prose should pass');
assert.ok(!result.stderr.includes('BLOCKED'), 'Should not emit a BLOCKED message');
})) passed++; else failed++;

if (await asyncTest('dev server blocker handles env/sudo prefixed dev commands', async () => {
const envResult = await runHookWithInput(devServerBlockerPath, {
tool_input: { command: 'env -i npm run dev' }
});
const sudoResult = await runHookWithInput(devServerBlockerPath, {
tool_input: { command: 'sudo -u root npm run dev' }
});

if (process.platform === 'win32') {
assert.strictEqual(envResult.code, 0, 'On Windows, hook should not block (exit 0)');
assert.strictEqual(sudoResult.code, 0, 'On Windows, hook should not block (exit 0)');
} else {
assert.strictEqual(envResult.code, 2, 'Env-prefixed dev command should be blocked');
assert.strictEqual(sudoResult.code, 2, 'Sudo-prefixed dev command should be blocked');
}
})) passed++; else failed++;

if (await asyncTest('hooks handle missing files gracefully', async () => {
const testDir = createTestDir();
const transcriptPath = path.join(testDir, 'nonexistent.jsonl');
Expand Down
Loading