diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index f5532a07ca..4d9c45ce17 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -327,8 +327,12 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation. ```bash #!/usr/bin/env bash -# Extract the plan path from the tool input JSON -plan_path=$(jq -r '.tool_input.plan_path // empty') +# Extract the plan filename from the tool input JSON +plan_filename=$(jq -r '.tool_input.plan_filename // empty') +plan_filename=$(basename -- "$plan_filename") + +# Construct the absolute path using the GEMINI_PLANS_DIR environment variable +plan_path="$GEMINI_PLANS_DIR/$plan_filename" if [ -f "$plan_path" ]; then # Generate a unique filename using a timestamp diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 0d6ae6d447..0125a28eb2 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -138,6 +138,7 @@ multiple layers in the following order of precedence (highest to lowest): Hooks are executed with a sanitized environment. - `GEMINI_PROJECT_DIR`: The absolute path to the project root. +- `GEMINI_PLANS_DIR`: The absolute path to the plans directory. - `GEMINI_SESSION_ID`: The unique ID for the current session. - `GEMINI_CWD`: The current working directory. - `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility. diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 9cee6575fe..5b155a8516 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -76,6 +76,9 @@ describe('HookRunner', () => { sanitizationConfig: { enableEnvironmentVariableRedaction: true, }, + storage: { + getPlansDir: vi.fn().mockReturnValue('/test/project/plans'), + }, } as unknown as Config; hookRunner = new HookRunner(mockConfig); @@ -370,12 +373,51 @@ describe('HookRunner', () => { shell: false, env: expect.objectContaining({ GEMINI_PROJECT_DIR: '/test/project', + GEMINI_PLANS_DIR: '/test/project/plans', + GEMINI_CWD: '/test/project', + GEMINI_SESSION_ID: 'test-session', CLAUDE_PROJECT_DIR: '/test/project', }), }), ); }); + it('should expand and escape GEMINI_PLANS_DIR in commands', async () => { + const configWithEnvVar: HookConfig = { + type: HookType.Command, + command: 'ls $GEMINI_PLANS_DIR', + }; + + // Change plans dir to one with spaces + vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue( + '/test/project/plans with spaces', + ); + + mockSpawn.mockProcessOn.mockImplementation( + (event: string, callback: (code: number) => void) => { + if (event === 'close') { + setImmediate(() => callback(0)); + } + }, + ); + + await hookRunner.executeHook( + configWithEnvVar, + HookEventName.BeforeTool, + mockInput, + ); + + expect(spawn).toHaveBeenCalledWith( + expect.stringMatching(/bash|powershell/), + expect.arrayContaining([ + expect.stringMatching( + /ls ['"]\/test\/project\/plans with spaces['"]/, + ), + ]), + expect.any(Object), + ); + }); + it('should not allow command injection via GEMINI_PROJECT_DIR', async () => { const maliciousCwd = '/test/project; echo "pwned" > /tmp/pwned'; const mockMaliciousInput: HookInput = { diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 812deafcbe..4c199ebfc7 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -348,6 +348,9 @@ export class HookRunner { const env = { ...sanitizeEnvironment(process.env, this.config.sanitizationConfig), GEMINI_PROJECT_DIR: input.cwd, + GEMINI_PLANS_DIR: this.config.storage.getPlansDir(), + GEMINI_CWD: input.cwd, + GEMINI_SESSION_ID: input.session_id, CLAUDE_PROJECT_DIR: input.cwd, // For compatibility ...hookConfig.env, }; @@ -514,8 +517,17 @@ export class HookRunner { ): string { debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); const escapedCwd = escapeShellArg(input.cwd, shellType); + const escapedPlansDir = escapeShellArg( + this.config.storage.getPlansDir(), + shellType, + ); + const escapedSessionId = escapeShellArg(input.session_id, shellType); + return command .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd) + .replace(/\$GEMINI_CWD/g, () => escapedCwd) + .replace(/\$GEMINI_PLANS_DIR/g, () => escapedPlansDir) + .replace(/\$GEMINI_SESSION_ID/g, () => escapedSessionId) .replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility }