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
8 changes: 8 additions & 0 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ Command hooks execute commands via child processes. Input JSON is passed through
}
```

`$QWEN_PROJECT_DIR` (and the `$GEMINI_PROJECT_DIR` / `$CLAUDE_PROJECT_DIR`
compatibility forms) is expanded before the command reaches the shell, not by
the shell itself. Outside quotes the expanded path is auto-quoted for you
(including any literal path suffix immediately after it, e.g. on Windows
`cmd.exe`); inside `"..."` the raw path is spliced into your existing quotes.
Comment on lines +84 to +86

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new paragraph reads "(including any literal path suffix immediately after it, e.g. on Windows cmd.exe)" as a general guarantee, but suffix absorption into the quoted region is implemented only in the cmd branch — the bash and PowerShell unquoted branches quote only the cwd and leave the suffix outside the quotes. A hook author writing "$QWEN_PROJECT_DIR/my scripts/check.sh" with cwd /tmp/my dir gets '/tmp/my dir'/my scripts/check.sh, which word-splits into /tmp/my dir/my + scripts/check.sh and fails with a confusing not-found — the documented suffix-quoting never happens outside cmd.exe (and even cmd stops absorbing at a space in the suffix).

Witness:

probe (real scanner):
  bash:       $QWEN_PROJECT_DIR/my scripts/check.sh (cwd '/tmp/my dir') -> '/tmp/my dir'/my scripts/check.sh
  powershell: -> 'C:/my dir'/my scripts/check.ps1
  cmd:        -> "C:/my dir/my" scripts/check.cmd   (absorbs only up to the space delimiter)
Suggested change
the shell itself. Outside quotes the expanded path is auto-quoted for you
(including any literal path suffix immediately after it, e.g. on Windows
`cmd.exe`); inside `"..."` the raw path is spliced into your existing quotes.
the shell itself. Outside quotes the expanded path is auto-quoted for you;
on Windows `cmd.exe`, any literal path suffix immediately after the
placeholder (e.g. `$QWEN_PROJECT_DIR/.qwen/hooks/check.cmd`) is pulled into
the same quoted region, since cmd does not concatenate quoted and unquoted
tokens. Inside `"..."` the raw path is spliced into your existing quotes.

— qwen3.8-max via Qwen Code /review (v0.22.3)

Inside bash `'...'`, nothing expands — that's standard bash single-quote
semantics, so the placeholder is left exactly as written.
Comment on lines +87 to +88

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The docs justify leaving a single-quoted placeholder untouched by quoting bash semantics ("nothing expands inside '...'"), but the sibling PowerShell branch substitutes inside '...' (embedded ' doubled) — and PowerShell single-quoted strings have the exact same no-expansion semantics. A team sharing one hooks config across Linux and Windows developers writes validate '$QWEN_PROJECT_DIR/config.json' and gets the literal placeholder under bash but the expanded path under shell: 'powershell' — silently different behavior per shell, with nothing in the docs to consult.

Witness:

probe (real executeHook, identical command both shells):
  bash       (cwd=/home/team/proj) => validate '$QWEN_PROJECT_DIR/config.json'
  powershell (cwd=C:/team/proj)    => validate 'C:/team/proj/config.json'

Add one sentence in this paragraph documenting the PowerShell behavior (inside PowerShell '...' the path is spliced in with embedded ' doubled), or align the two branches — note the PowerShell splice is pinned by the test doubles an embedded single quote when splicing into a PowerShell single-quoted placeholder, which an alignment fix must update deliberately.

— qwen3.8-max via Qwen Code /review (v0.22.3)


### HTTP Hooks

HTTP hooks send hook input as POST requests to specified URLs. They support URL whitelists, DNS-level SSRF protection, environment variable interpolation, and other security features.
Expand Down
158 changes: 157 additions & 1 deletion packages/core/src/hooks/hook-runner.process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
*/

import { spawn, spawnSync } from 'node:child_process';
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -67,6 +74,155 @@ const isRunning = (pid: number): boolean => {
return !result.stdout.trim().startsWith('Z');
};

describe.runIf(process.platform === 'win32')(
'HookRunner Windows project directory expansion',
() => {
it.each(['QWEN_PROJECT_DIR', 'GEMINI_PROJECT_DIR', 'CLAUDE_PROJECT_DIR'])(
'expands $%s for cmd.exe',
async (variable) => {
const tempDir = await mkdtemp(
join(tmpdir(), "qwen hook (project) & 'quoted' "),
);

try {
const runner = new HookRunner();
const input: HookInput = {
session_id: 'project-dir-test',
transcript_path: join(tempDir, 'transcript.jsonl'),
cwd: tempDir,
hook_event_name: HookEventName.PreToolUse,
timestamp: new Date().toISOString(),
};

const result = await runner.executeHook(
{
type: HookType.Command,
command: `if exist $${variable} (echo FOUND) else (echo MISSING)`,
source: HooksConfigSource.Project,
},
HookEventName.PreToolUse,
input,
);

expect(result.success).toBe(true);
expect(result.stdout?.trim()).toBe('FOUND');
} finally {
await rm(tempDir, { recursive: true, force: true });
}
},
);

it('expands QWEN_PROJECT_DIR for PowerShell', async () => {
const tempDir = await mkdtemp(
join(tmpdir(), "qwen hook (project) & 'quoted' "),
);

try {
const runner = new HookRunner();
const input: HookInput = {
session_id: 'project-dir-test',
transcript_path: join(tempDir, 'transcript.jsonl'),
cwd: tempDir,
hook_event_name: HookEventName.PreToolUse,
timestamp: new Date().toISOString(),
};

const result = await runner.executeHook(
{
type: HookType.Command,
command:
"if (Test-Path $QWEN_PROJECT_DIR) { Write-Output 'FOUND' } else { Write-Output 'MISSING' }",
source: HooksConfigSource.Project,
shell: 'powershell',
},
HookEventName.PreToolUse,
input,
);

expect(result.success).toBe(true);
expect(result.stdout?.trim()).toBe('FOUND');
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});

it('runs a documented placeholder-first command through cmd.exe', async () => {
const tempDir = await mkdtemp(
join(tmpdir(), "qwen hook (project) & 'quoted' "),
);
const hookPath = join(tempDir, '.qwen', 'hooks', 'security-check.cmd');

try {
await mkdir(join(tempDir, '.qwen', 'hooks'), { recursive: true });
await writeFile(hookPath, '@echo FOUND\r\n', { encoding: 'utf8' });
const runner = new HookRunner();
const input: HookInput = {
session_id: 'project-dir-test',
transcript_path: join(tempDir, 'transcript.jsonl'),
cwd: tempDir,
hook_event_name: HookEventName.PreToolUse,
timestamp: new Date().toISOString(),
};

const result = await runner.executeHook(
{
type: HookType.Command,
command: '$QWEN_PROJECT_DIR/.qwen/hooks/security-check.cmd',
source: HooksConfigSource.Project,
},
HookEventName.PreToolUse,
input,
);

expect(result.success).toBe(true);
expect(result.stdout?.trim()).toBe('FOUND');
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});

it('expands and verbatim-quotes $QWEN_PROJECT_DIR for a parent-exit-surviving cmd.exe hook', async () => {
// Surviving hooks (MessageDisplay/StopFailure/SessionDelete) run
// through the detached SURVIVING_HOOK_SUPERVISOR_SOURCE process
// instead of the regular spawn path, so this exercises the
// windowsVerbatimArguments wiring threaded into that separate path.
const tempDir = await mkdtemp(
join(tmpdir(), "qwen hook (project) & 'quoted' "),
);

try {
const runner = new HookRunner();
const input: HookInput = {
session_id: 'project-dir-test',
transcript_path: join(tempDir, 'transcript.jsonl'),
cwd: tempDir,
hook_event_name: HookEventName.MessageDisplay,
timestamp: new Date().toISOString(),
};

const result = await runner.executeHook(
{
type: HookType.Command,
// exit codes diverge on the two branches (0 vs 3) since the
// supervisor discards hook stdout, so `... else (echo MISSING)`
// would leave both outcomes indistinguishable at exit code 0.
command:
'if exist "$QWEN_PROJECT_DIR" (echo FOUND) else (exit 3)',
source: HooksConfigSource.Project,
},
HookEventName.MessageDisplay,
input,
);

expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
},
);

describe.skipIf(process.platform === 'win32')(
'HookRunner process tree cancellation',
() => {
Expand Down
Loading
Loading