diff --git a/.github/workflows/gemini-automated-issue-dedup.yml b/.github/workflows/gemini-automated-issue-dedup.yml index ff64f768973..b0bf7d5d2fe 100644 --- a/.github/workflows/gemini-automated-issue-dedup.yml +++ b/.github/workflows/gemini-automated-issue-dedup.yml @@ -28,7 +28,10 @@ jobs: if: |- github.repository == 'google-gemini/gemini-cli' && vars.TRIAGE_DEDUPLICATE_ISSUES != '' && - (github.event_name == 'issues' || + ((github.event_name == 'issues' && + (github.event.issue.author_association == 'OWNER' || + github.event.issue.author_association == 'MEMBER' || + github.event.issue.author_association == 'COLLABORATOR')) || github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@gemini-cli /deduplicate') && @@ -62,7 +65,7 @@ jobs: uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 id: 'gemini_issue_deduplication' env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GITHUB_TOKEN: '' ISSUE_TITLE: '${{ github.event.issue.title }}' ISSUE_BODY: '${{ github.event.issue.body }}' ISSUE_NUMBER: '${{ github.event.issue.number }}' diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 0e9034a8c72..ed971196cb8 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -24,6 +24,7 @@ import { normalizeCommand, hasRedirection, resolveExecutable, + detectCommandSubstitution, } from './shell-utils.js'; import path from 'node:path'; @@ -676,3 +677,200 @@ describe('resolveExecutable', () => { expect(resolveExecutable('anything')).toBeUndefined(); }); }); + +describe('detectCommandSubstitution', () => { + beforeAll(async () => { + await initializeShellParsers(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('should block variable expansion on both platforms', () => { + it.each([ + 'echo $GITHUB_TOKEN', + 'echo $GOOGLE_CLOUD_ACCESS_TOKEN', + 'echo $ACTIONS_ID_TOKEN_REQUEST_TOKEN', + 'echo $GEMINI_API_KEY', + 'echo ${GITHUB_TOKEN}', + 'echo ${GOOGLE_CLOUD_ACCESS_TOKEN}', + 'echo "value=$GITHUB_TOKEN"', + 'echo "hello $USER"', + 'cat $HOME/.ssh/id_rsa', + ])('blocks on both bash and PowerShell: %s', (cmd) => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution(cmd)).toBe(true); + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + expect(detectCommandSubstitution(cmd)).toBe(true); + }); + }); + + describe('should block bash-specific substitution', () => { + it.each([ + 'echo $(whoami)', + 'echo `whoami`', + 'cat <(ls)', + 'diff <(cat a) >(cat b)', + ])('blocks: %s', (cmd) => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution(cmd)).toBe(true); + }); + }); + + describe('should block PowerShell-specific variable syntax', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + }); + + it.each([ + 'Write-Output $env:GEMINI_API_KEY', + 'Write-Output $env:GITHUB_TOKEN', + 'echo $env:GOOGLE_CLOUD_ACCESS_TOKEN', + 'echo ${env:GEMINI_API_KEY}', + 'echo ${env:GITHUB_TOKEN}', + 'Write-Output "token=$env:GEMINI_API_KEY"', + 'echo "DUPLICATE_ISSUES_CSV=$env:TOKEN" >> file', + ])('blocks: %s', (cmd) => { + expect(detectCommandSubstitution(cmd)).toBe(true); + }); + }); + + describe('should block PowerShell subexpressions', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + }); + + it.each(['echo $(Get-Process)', 'Write-Output @(1,2,3)'])( + 'blocks: %s', + (cmd) => { + expect(detectCommandSubstitution(cmd)).toBe(true); + }, + ); + }); + + describe('should allow safe commands on both platforms', () => { + it.each([ + 'echo hello world', + 'echo 42', + "echo 'literal $VAR not expanded'", + ])('allows on both bash and PowerShell: %s', (cmd) => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution(cmd)).toBe(false); + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + expect(detectCommandSubstitution(cmd)).toBe(false); + }); + }); + + describe('should allow safe bash-specific commands', () => { + it.each(['ls -la /tmp', 'cat /etc/hostname', 'grep -r "pattern" .'])( + 'allows: %s', + (cmd) => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution(cmd)).toBe(false); + }, + ); + }); + + describe('should allow safe PowerShell-specific commands', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + }); + + it.each([ + 'Get-ChildItem C:\\Users', + 'Write-Output "hello world"', + "echo 'literal $env:VAR not expanded'", + 'dir C:\\temp', + ])('allows: %s', (cmd) => { + expect(detectCommandSubstitution(cmd)).toBe(false); + }); + }); + + it('should allow escaped dollar in double quotes (bash)', () => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution('echo "price is \\$5"')).toBe(false); + }); + + it('should allow dollar at end of string on both platforms', () => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution('echo cost is $')).toBe(false); + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + expect(detectCommandSubstitution('echo cost is $')).toBe(false); + }); + + it('should allow backtick-escaped dollar (PowerShell)', () => { + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + expect(detectCommandSubstitution('echo `$env:VAR')).toBe(false); + }); + + it('should allow $VAR inside single quotes on both platforms', () => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution("echo '$USER'")).toBe(false); + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + expect(detectCommandSubstitution("echo '$env:SECRET'")).toBe(false); + }); + + describe('should allow bash safe automatic variables', () => { + it('allows $_ (last argument)', () => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution('echo $_')).toBe(false); + }); + + it('allows $_ followed by space', () => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution('echo $_ something')).toBe(false); + }); + + it('blocks $_SECRET (not an exact match)', () => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution('echo $_SECRET')).toBe(true); + }); + + it('blocks $_1 (not an exact match)', () => { + mockPlatform.mockReturnValue('linux'); + expect(detectCommandSubstitution('echo $_1')).toBe(true); + }); + }); + + describe('should allow PowerShell safe automatic variables', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('win32'); + vi.stubEnv('ComSpec', 'powershell.exe'); + }); + + it.each([ + 'Write-Output $true', + 'Write-Output $True', + 'Write-Output $TRUE', + 'Write-Output $false', + 'Write-Output $FALSE', + 'Write-Output $null', + 'Write-Output $Null', + 'ForEach-Object { $_ }', + 'echo $args', + 'echo $ARGS', + ])('allows: %s', (cmd) => { + expect(detectCommandSubstitution(cmd)).toBe(false); + }); + + it.each([ + 'echo $trueVar', + 'echo $_FOO', + 'echo $TOKEN', + 'echo $env:SECRET', + 'echo $nullify', + 'echo $true:SECRET', + ])('blocks: %s', (cmd) => { + expect(detectCommandSubstitution(cmd)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index dc2df2e32d7..943db0a213d 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -1092,6 +1092,10 @@ export function detectCommandSubstitution(command: string): boolean { return detectBashSubstitution(command); } +const BASH_SAFE_VARS = new Set(['_']); +const PS_SAFE_VARS = new Set(['true', 'false', 'null', 'args', '_']); +const VAR_NAME_RE = /[a-zA-Z_]\w*/y; + function detectBashSubstitution(command: string): boolean { let inSingleQuote = false; let inDoubleQuote = false; @@ -1124,8 +1128,26 @@ function detectBashSubstitution(command: string): boolean { continue; } } - if (char === '$' && command[i + 1] === '(') { - return true; + if (char === '$' && i + 1 < command.length) { + const next = command[i + 1]; + // Block $() command substitution + if (next === '(') { + return true; + } + // Block ${VAR} variable expansion + if (next === '{') { + return true; + } + // Block $VAR variable expansion ($ followed by letter or underscore) + // Allow known-safe bash automatic variables: $_ (last argument) + if (/[a-zA-Z_]/.test(next)) { + VAR_NAME_RE.lastIndex = i + 1; + const varMatch = VAR_NAME_RE.exec(command); + const varName = varMatch ? varMatch[0] : ''; + if (!BASH_SAFE_VARS.has(varName)) { + return true; + } + } } if ( !inDoubleQuote && @@ -1171,8 +1193,29 @@ function detectPowerShellSubstitution(command: string): boolean { i += 2; continue; } - if (char === '$' && command[i + 1] === '(') { - return true; + if (char === '$' && i + 1 < command.length) { + const next = command[i + 1]; + // Block $() subexpression + if (next === '(') { + return true; + } + // Block ${...} braced variable expansion (e.g. ${env:GITHUB_TOKEN}) + if (next === '{') { + return true; + } + // Block $VAR bare variable expansion (e.g. $env:GEMINI_API_KEY) + // Allow known-safe PowerShell automatic variables + if (/[a-zA-Z_]/.test(next)) { + VAR_NAME_RE.lastIndex = i + 1; + const varMatch = VAR_NAME_RE.exec(command); + const varName = varMatch ? varMatch[0].toLowerCase() : ''; + if ( + !PS_SAFE_VARS.has(varName) || + command[i + 1 + varName.length] === ':' + ) { + return true; + } + } } if (!inDoubleQuote && char === '@' && command[i + 1] === '(') { return true;