Skip to content

Commit a4318f2

Browse files
fix(core): expose GEMINI_PLANS_DIR to hook environment (#25296)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 82e8d67 commit a4318f2

4 files changed

Lines changed: 61 additions & 2 deletions

File tree

docs/cli/plan-mode.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,12 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation.
327327

328328
```bash
329329
#!/usr/bin/env bash
330-
# Extract the plan path from the tool input JSON
331-
plan_path=$(jq -r '.tool_input.plan_path // empty')
330+
# Extract the plan filename from the tool input JSON
331+
plan_filename=$(jq -r '.tool_input.plan_filename // empty')
332+
plan_filename=$(basename -- "$plan_filename")
333+
334+
# Construct the absolute path using the GEMINI_PLANS_DIR environment variable
335+
plan_path="$GEMINI_PLANS_DIR/$plan_filename"
332336

333337
if [ -f "$plan_path" ]; then
334338
# Generate a unique filename using a timestamp

docs/hooks/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ multiple layers in the following order of precedence (highest to lowest):
138138
Hooks are executed with a sanitized environment.
139139

140140
- `GEMINI_PROJECT_DIR`: The absolute path to the project root.
141+
- `GEMINI_PLANS_DIR`: The absolute path to the plans directory.
141142
- `GEMINI_SESSION_ID`: The unique ID for the current session.
142143
- `GEMINI_CWD`: The current working directory.
143144
- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility.

packages/core/src/hooks/hookRunner.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ describe('HookRunner', () => {
7676
sanitizationConfig: {
7777
enableEnvironmentVariableRedaction: true,
7878
},
79+
storage: {
80+
getPlansDir: vi.fn().mockReturnValue('/test/project/plans'),
81+
},
7982
} as unknown as Config;
8083

8184
hookRunner = new HookRunner(mockConfig);
@@ -370,12 +373,51 @@ describe('HookRunner', () => {
370373
shell: false,
371374
env: expect.objectContaining({
372375
GEMINI_PROJECT_DIR: '/test/project',
376+
GEMINI_PLANS_DIR: '/test/project/plans',
377+
GEMINI_CWD: '/test/project',
378+
GEMINI_SESSION_ID: 'test-session',
373379
CLAUDE_PROJECT_DIR: '/test/project',
374380
}),
375381
}),
376382
);
377383
});
378384

385+
it('should expand and escape GEMINI_PLANS_DIR in commands', async () => {
386+
const configWithEnvVar: HookConfig = {
387+
type: HookType.Command,
388+
command: 'ls $GEMINI_PLANS_DIR',
389+
};
390+
391+
// Change plans dir to one with spaces
392+
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(
393+
'/test/project/plans with spaces',
394+
);
395+
396+
mockSpawn.mockProcessOn.mockImplementation(
397+
(event: string, callback: (code: number) => void) => {
398+
if (event === 'close') {
399+
setImmediate(() => callback(0));
400+
}
401+
},
402+
);
403+
404+
await hookRunner.executeHook(
405+
configWithEnvVar,
406+
HookEventName.BeforeTool,
407+
mockInput,
408+
);
409+
410+
expect(spawn).toHaveBeenCalledWith(
411+
expect.stringMatching(/bash|powershell/),
412+
expect.arrayContaining([
413+
expect.stringMatching(
414+
/ls ['"]\/test\/project\/plans with spaces['"]/,
415+
),
416+
]),
417+
expect.any(Object),
418+
);
419+
});
420+
379421
it('should not allow command injection via GEMINI_PROJECT_DIR', async () => {
380422
const maliciousCwd = '/test/project; echo "pwned" > /tmp/pwned';
381423
const mockMaliciousInput: HookInput = {

packages/core/src/hooks/hookRunner.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,9 @@ export class HookRunner {
348348
const env = {
349349
...sanitizeEnvironment(process.env, this.config.sanitizationConfig),
350350
GEMINI_PROJECT_DIR: input.cwd,
351+
GEMINI_PLANS_DIR: this.config.storage.getPlansDir(),
352+
GEMINI_CWD: input.cwd,
353+
GEMINI_SESSION_ID: input.session_id,
351354
CLAUDE_PROJECT_DIR: input.cwd, // For compatibility
352355
...hookConfig.env,
353356
};
@@ -514,8 +517,17 @@ export class HookRunner {
514517
): string {
515518
debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`);
516519
const escapedCwd = escapeShellArg(input.cwd, shellType);
520+
const escapedPlansDir = escapeShellArg(
521+
this.config.storage.getPlansDir(),
522+
shellType,
523+
);
524+
const escapedSessionId = escapeShellArg(input.session_id, shellType);
525+
517526
return command
518527
.replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd)
528+
.replace(/\$GEMINI_CWD/g, () => escapedCwd)
529+
.replace(/\$GEMINI_PLANS_DIR/g, () => escapedPlansDir)
530+
.replace(/\$GEMINI_SESSION_ID/g, () => escapedSessionId)
519531
.replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility
520532
}
521533

0 commit comments

Comments
 (0)