Skip to content
Merged
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: 6 additions & 2 deletions docs/cli/plan-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Comment thread
Adib234 marked this conversation as resolved.
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
Expand Down
1 change: 1 addition & 0 deletions docs/hooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/hooks/hookRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Comment thread
Adib234 marked this conversation as resolved.
}),
}),
);
});

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 = {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/hooks/hookRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment thread
Adib234 marked this conversation as resolved.
GEMINI_CWD: input.cwd,
GEMINI_SESSION_ID: input.session_id,
CLAUDE_PROJECT_DIR: input.cwd, // For compatibility
...hookConfig.env,
};
Expand Down Expand Up @@ -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
}

Expand Down
Loading