From f78b69f4f408925712ebe9d741dc3cc9d1471572 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 09:25:54 +0000 Subject: [PATCH 1/2] test: add coverage for excluded-vars and shell-utils security paths - excluded-vars.test.ts: Tests for buildExclusionSet() which controls which env vars are passed to the agent container. Covers: - Base exclusions always present (PATH, sudo, GH Actions tokens, proxy vars) - API key exclusion when enableApiProxy=true (OPENAI, ANTHROPIC, COPILOT, GEMINI) - API keys NOT excluded when enableApiProxy=false - GITHUB_TOKEN/GH_TOKEN excluded when DIFC proxy is configured - Custom excludeEnv entries - shell-utils.test.ts: Tests for shell argument escaping (injection prevention). Covers: - Safe characters pass through unquoted - Shell metacharacters (spaces, $, ;, |, &, >, <) get single-quoted - Single-quote injection prevention via the '\'' pattern - Complex injection strings with mixed metacharacters - joinShellArgs edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/parsers/shell-utils.test.ts | 123 ++++++++++++ .../agent-environment/excluded-vars.test.ts | 187 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 src/parsers/shell-utils.test.ts create mode 100644 src/services/agent-environment/excluded-vars.test.ts diff --git a/src/parsers/shell-utils.test.ts b/src/parsers/shell-utils.test.ts new file mode 100644 index 00000000..2452717b --- /dev/null +++ b/src/parsers/shell-utils.test.ts @@ -0,0 +1,123 @@ +import { escapeShellArg, joinShellArgs } from './shell-utils'; + +describe('escapeShellArg', () => { + describe('safe characters (no quoting needed)', () => { + it('should return simple alphanumeric strings as-is', () => { + expect(escapeShellArg('hello')).toBe('hello'); + expect(escapeShellArg('abc123')).toBe('abc123'); + }); + + it('should return strings with allowed safe chars as-is', () => { + expect(escapeShellArg('file.txt')).toBe('file.txt'); + expect(escapeShellArg('/usr/bin/node')).toBe('/usr/bin/node'); + expect(escapeShellArg('key=value')).toBe('key=value'); + expect(escapeShellArg('host:port')).toBe('host:port'); + expect(escapeShellArg('my-file')).toBe('my-file'); + expect(escapeShellArg('my_var')).toBe('my_var'); + }); + }); + + describe('strings requiring quoting', () => { + it('should wrap strings with spaces in single quotes', () => { + expect(escapeShellArg('hello world')).toBe("'hello world'"); + }); + + it('should wrap strings with dollar signs in single quotes', () => { + expect(escapeShellArg('$HOME')).toBe("'$HOME'"); + }); + + it('should wrap strings with backticks in single quotes', () => { + expect(escapeShellArg('`cmd`')).toBe("'`cmd`'"); + }); + + it('should wrap strings with semicolons in single quotes (command injection prevention)', () => { + expect(escapeShellArg('; rm -rf /')).toBe("'; rm -rf /'"); + }); + + it('should wrap strings with ampersands in single quotes', () => { + expect(escapeShellArg('a && b')).toBe("'a && b'"); + }); + + it('should wrap strings with pipes in single quotes', () => { + expect(escapeShellArg('a | b')).toBe("'a | b'"); + }); + + it('should wrap strings with redirect operators in single quotes', () => { + expect(escapeShellArg('a > b')).toBe("'a > b'"); + expect(escapeShellArg('a < b')).toBe("'a < b'"); + }); + + it('should wrap strings with exclamation marks in single quotes', () => { + expect(escapeShellArg('hello!')).toBe("'hello!'"); + }); + + it('should wrap strings with newlines in single quotes', () => { + expect(escapeShellArg('line1\nline2')).toBe("'line1\nline2'"); + }); + }); + + describe('strings with single quotes (injection prevention)', () => { + it('should escape single quotes using the standard shell pattern', () => { + expect(escapeShellArg("it's")).toBe("'it'\\''s'"); + }); + + it('should handle strings that are only a single quote', () => { + expect(escapeShellArg("'")).toBe("''\\'''"); + }); + + it('should handle strings with multiple single quotes', () => { + expect(escapeShellArg("a'b'c")).toBe("'a'\\''b'\\''c'"); + }); + + it('should handle injection attempt with single quote and shell metacharacters', () => { + const injection = "'; rm -rf /; echo '"; + const escaped = escapeShellArg(injection); + // Should be safely quoted so no shell injection can occur + // The two surrounding ' chars and the embedded '\'' escapes neutralize all metacharacters + expect(escaped).toBe("''\\''; rm -rf /; echo '\\'''" ); + }); + }); + + describe('empty and edge cases', () => { + it('should wrap empty string in single quotes', () => { + // Empty string has no safe chars, should be wrapped + const result = escapeShellArg(''); + // Empty string has no special chars but also no safe chars - it passes the regex test + // because the regex requires at least one char to match [a-zA-Z0-9...]+ + // The test fails on empty string, so it should be quoted + expect(result).toBe("''"); + }); + + it('should handle strings with only special characters', () => { + expect(escapeShellArg('***')).toBe("'***'"); + }); + }); +}); + +describe('joinShellArgs', () => { + it('should join simple arguments with spaces', () => { + expect(joinShellArgs(['echo', 'hello'])).toBe('echo hello'); + }); + + it('should escape arguments with spaces', () => { + expect(joinShellArgs(['echo', 'hello world'])).toBe("echo 'hello world'"); + }); + + it('should handle empty array', () => { + expect(joinShellArgs([])).toBe(''); + }); + + it('should handle single argument', () => { + expect(joinShellArgs(['echo'])).toBe('echo'); + }); + + it('should properly escape injection attempts in argument list', () => { + const args = ['cmd', '--flag', '; malicious command']; + const result = joinShellArgs(args); + expect(result).toBe("cmd --flag '; malicious command'"); + }); + + it('should handle arguments with dollar signs', () => { + expect(joinShellArgs(['echo', '$SECRET'])).toBe("echo '$SECRET'"); + }); +}); diff --git a/src/services/agent-environment/excluded-vars.test.ts b/src/services/agent-environment/excluded-vars.test.ts new file mode 100644 index 00000000..f9b0bd22 --- /dev/null +++ b/src/services/agent-environment/excluded-vars.test.ts @@ -0,0 +1,187 @@ +import { buildExclusionSet } from './excluded-vars'; +import { PROXY_ENV_VARS } from '../../upstream-proxy'; +import { WrapperConfig } from '../../types'; + +// Minimal WrapperConfig for tests +function makeConfig(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: [], + ...overrides, + } as WrapperConfig; +} + +describe('buildExclusionSet', () => { + describe('base exclusions (always excluded)', () => { + it('should always exclude PATH', () => { + const set = buildExclusionSet(makeConfig()); + expect(set.has('PATH')).toBe(true); + }); + + it('should always exclude shell state variables', () => { + const set = buildExclusionSet(makeConfig()); + expect(set.has('PWD')).toBe(true); + expect(set.has('OLDPWD')).toBe(true); + expect(set.has('SHLVL')).toBe(true); + expect(set.has('_')).toBe(true); + }); + + it('should always exclude sudo variables', () => { + const set = buildExclusionSet(makeConfig()); + expect(set.has('SUDO_COMMAND')).toBe(true); + expect(set.has('SUDO_USER')).toBe(true); + expect(set.has('SUDO_UID')).toBe(true); + expect(set.has('SUDO_GID')).toBe(true); + }); + + it('should always exclude GitHub Actions token variables', () => { + const set = buildExclusionSet(makeConfig()); + expect(set.has('ACTIONS_RUNTIME_TOKEN')).toBe(true); + expect(set.has('ACTIONS_RESULTS_URL')).toBe(true); + }); + + it('should always exclude AWF internal variables', () => { + const set = buildExclusionSet(makeConfig()); + expect(set.has('AWF_PREFLIGHT_BINARY')).toBe(true); + expect(set.has('AWF_STAGED_RUNNER_BINARY_NAME')).toBe(true); + expect(set.has('AWF_GEMINI_ENABLED')).toBe(true); + expect(set.has('MCP_GATEWAY_HOST_DOMAIN')).toBe(true); + }); + + it('should always exclude all proxy env vars', () => { + const set = buildExclusionSet(makeConfig()); + for (const v of PROXY_ENV_VARS) { + expect(set.has(v)).toBe(true); + } + }); + }); + + describe('when enableApiProxy is true (security-critical)', () => { + const config = makeConfig({ enableApiProxy: true }); + + it('should exclude OPENAI_API_KEY', () => { + expect(buildExclusionSet(config).has('OPENAI_API_KEY')).toBe(true); + }); + + it('should exclude OPENAI_KEY', () => { + expect(buildExclusionSet(config).has('OPENAI_KEY')).toBe(true); + }); + + it('should exclude CODEX_API_KEY', () => { + expect(buildExclusionSet(config).has('CODEX_API_KEY')).toBe(true); + }); + + it('should exclude ANTHROPIC_API_KEY', () => { + expect(buildExclusionSet(config).has('ANTHROPIC_API_KEY')).toBe(true); + }); + + it('should exclude CLAUDE_API_KEY', () => { + expect(buildExclusionSet(config).has('CLAUDE_API_KEY')).toBe(true); + }); + + it('should exclude COPILOT_GITHUB_TOKEN', () => { + expect(buildExclusionSet(config).has('COPILOT_GITHUB_TOKEN')).toBe(true); + }); + + it('should exclude COPILOT_API_KEY', () => { + expect(buildExclusionSet(config).has('COPILOT_API_KEY')).toBe(true); + }); + + it('should exclude COPILOT_PROVIDER_API_KEY', () => { + expect(buildExclusionSet(config).has('COPILOT_PROVIDER_API_KEY')).toBe(true); + }); + + it('should exclude GEMINI_API_KEY', () => { + expect(buildExclusionSet(config).has('GEMINI_API_KEY')).toBe(true); + }); + + it('should exclude GOOGLE_GEMINI_BASE_URL', () => { + expect(buildExclusionSet(config).has('GOOGLE_GEMINI_BASE_URL')).toBe(true); + }); + + it('should exclude GEMINI_API_BASE_URL', () => { + expect(buildExclusionSet(config).has('GEMINI_API_BASE_URL')).toBe(true); + }); + }); + + describe('when enableApiProxy is false', () => { + const config = makeConfig({ enableApiProxy: false }); + + it('should NOT exclude OPENAI_API_KEY', () => { + expect(buildExclusionSet(config).has('OPENAI_API_KEY')).toBe(false); + }); + + it('should NOT exclude ANTHROPIC_API_KEY', () => { + expect(buildExclusionSet(config).has('ANTHROPIC_API_KEY')).toBe(false); + }); + + it('should NOT exclude COPILOT_GITHUB_TOKEN', () => { + expect(buildExclusionSet(config).has('COPILOT_GITHUB_TOKEN')).toBe(false); + }); + + it('should NOT exclude GEMINI_API_KEY', () => { + expect(buildExclusionSet(config).has('GEMINI_API_KEY')).toBe(false); + }); + }); + + describe('when difcProxyHost is set (DIFC proxy security)', () => { + const config = makeConfig({ difcProxyHost: 'host.docker.internal:18443' }); + + it('should exclude GITHUB_TOKEN', () => { + expect(buildExclusionSet(config).has('GITHUB_TOKEN')).toBe(true); + }); + + it('should exclude GH_TOKEN', () => { + expect(buildExclusionSet(config).has('GH_TOKEN')).toBe(true); + }); + }); + + describe('when difcProxyHost is not set', () => { + const config = makeConfig({ difcProxyHost: undefined }); + + it('should NOT exclude GITHUB_TOKEN', () => { + expect(buildExclusionSet(config).has('GITHUB_TOKEN')).toBe(false); + }); + + it('should NOT exclude GH_TOKEN', () => { + expect(buildExclusionSet(config).has('GH_TOKEN')).toBe(false); + }); + }); + + describe('when excludeEnv is set', () => { + it('should exclude all custom env vars', () => { + const config = makeConfig({ excludeEnv: ['MY_SECRET', 'ANOTHER_VAR'] }); + const set = buildExclusionSet(config); + expect(set.has('MY_SECRET')).toBe(true); + expect(set.has('ANOTHER_VAR')).toBe(true); + }); + + it('should handle empty excludeEnv array', () => { + const config = makeConfig({ excludeEnv: [] }); + const set = buildExclusionSet(config); + // Base exclusions still present + expect(set.has('PATH')).toBe(true); + }); + + it('should handle undefined excludeEnv', () => { + const config = makeConfig({ excludeEnv: undefined }); + const set = buildExclusionSet(config); + // Base exclusions still present + expect(set.has('PATH')).toBe(true); + }); + }); + + describe('combined configurations', () => { + it('should combine apiProxy and difc exclusions', () => { + const config = makeConfig({ + enableApiProxy: true, + difcProxyHost: 'host.docker.internal:18443', + excludeEnv: ['CUSTOM_SECRET'], + }); + const set = buildExclusionSet(config); + expect(set.has('ANTHROPIC_API_KEY')).toBe(true); + expect(set.has('GITHUB_TOKEN')).toBe(true); + expect(set.has('CUSTOM_SECRET')).toBe(true); + expect(set.has('PATH')).toBe(true); + }); + }); +}); From 5e52eb60795f8733d4c5d984559d62c67c6b5a32 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 27 May 2026 08:03:47 -0700 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/parsers/shell-utils.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/parsers/shell-utils.test.ts b/src/parsers/shell-utils.test.ts index 2452717b..e42f4b7e 100644 --- a/src/parsers/shell-utils.test.ts +++ b/src/parsers/shell-utils.test.ts @@ -80,11 +80,9 @@ describe('escapeShellArg', () => { describe('empty and edge cases', () => { it('should wrap empty string in single quotes', () => { - // Empty string has no safe chars, should be wrapped + // Empty string does not match the safe-character regex because it requires at least one character, + // so it should be quoted. const result = escapeShellArg(''); - // Empty string has no special chars but also no safe chars - it passes the regex test - // because the regex requires at least one char to match [a-zA-Z0-9...]+ - // The test fails on empty string, so it should be quoted expect(result).toBe("''"); });