From e295c70d11a308febf78734be333d40144b78495 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:59:47 +0000 Subject: [PATCH 1/7] test: add branch coverage for 3 low-coverage modules Cover previously uncovered branches: - audit-enricher-extra.test.ts: domain edge cases (dash/empty domain, regex metachar detection, invalid-regex catch), HTTP-only protocol matching, unknown-fallback path, transaction-end skip in computeRuleStats, and unknown-hit counting - validators/log-and-limits.test.ts: all validation error exit paths (logLevel, maxEffectiveTokens, maxAiCredits, multipliers, maxRuns, maxPermissionDenied, memoryLimit, agentImage) and success paths - agent-volumes/docker-host-staging.test.ts: shouldUseDockerHostStaging edge cases, stageHostFile directory/traversal rejection, and extractCommandBinaryName with empty/unsafe names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../validators/log-and-limits.test.ts | 236 ++++++++++++++ src/logs/audit-enricher-extra.test.ts | 300 ++++++++++++++++++ .../agent-volumes/docker-host-staging.test.ts | 191 +++++++++++ 3 files changed, 727 insertions(+) create mode 100644 src/commands/validators/log-and-limits.test.ts create mode 100644 src/logs/audit-enricher-extra.test.ts create mode 100644 src/services/agent-volumes/docker-host-staging.test.ts diff --git a/src/commands/validators/log-and-limits.test.ts b/src/commands/validators/log-and-limits.test.ts new file mode 100644 index 00000000..7b67fb37 --- /dev/null +++ b/src/commands/validators/log-and-limits.test.ts @@ -0,0 +1,236 @@ +/** + * Tests for log-and-limits.ts – validateLogAndLimits function. + * Covers validation success paths and all error branches. + */ + +jest.mock('../../logger', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + setLevel: jest.fn(), + }, +})); + +jest.mock('../../api-proxy-config', () => ({ + validateAnthropicCacheTailTtl: jest.fn(), +})); + +// parseMemoryLimit crashes on undefined input (pre-existing behaviour); mock it +// so tests can control the success/error return independently of memoryLimit. +jest.mock('../../option-parsers', () => { + const actual = jest.requireActual('../../option-parsers'); + return { + ...actual, + parseMemoryLimit: jest.fn().mockReturnValue({ value: undefined }), + }; +}); + +// processAgentImageOption is mocked to decouple agentImage validation from tests +// that don't need it; individual tests override this as needed. +jest.mock('../../domain-utils', () => { + const actual = jest.requireActual('../../domain-utils'); + return { + ...actual, + processAgentImageOption: jest.fn().mockReturnValue({ agentImage: 'default', isPreset: true }), + }; +}); + +import { validateLogAndLimits } from './log-and-limits'; +import { logger } from '../../logger'; +import { parseMemoryLimit } from '../../option-parsers'; +import { processAgentImageOption } from '../../domain-utils'; + +function minimalOptions(overrides: Record = {}): Record { + return { + logLevel: 'info', + buildLocal: false, + agentImage: undefined, + ...overrides, + }; +} + +function spyExit(): jest.SpyInstance { + return jest.spyOn(process, 'exit').mockImplementation((_code?: string | number | null | undefined) => { + throw new Error('process.exit called'); + }); +} + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + (parseMemoryLimit as jest.Mock).mockReturnValue({ value: undefined }); + (processAgentImageOption as jest.Mock).mockReturnValue({ agentImage: 'default', isPreset: true }); +}); + +describe('validateLogAndLimits – success paths', () => { + it('returns defaults for a minimal valid options object', () => { + const result = validateLogAndLimits(minimalOptions()); + expect(result.logLevel).toBe('info'); + expect(result.maxEffectiveTokens).toBeUndefined(); + expect(result.maxRuns).toBeUndefined(); + expect(result.memoryLimit).toBeUndefined(); + expect(result.agentImage).toBe('default'); + }); + + it('accepts all four valid log levels', () => { + for (const level of ['debug', 'info', 'warn', 'error'] as const) { + const result = validateLogAndLimits(minimalOptions({ logLevel: level })); + expect(result.logLevel).toBe(level); + } + }); + + it('returns valid maxEffectiveTokens as a number', () => { + const result = validateLogAndLimits(minimalOptions({ maxEffectiveTokens: '1000' })); + expect(result.maxEffectiveTokens).toBe(1000); + }); + + it('returns valid maxRuns as a number', () => { + const result = validateLogAndLimits(minimalOptions({ maxRuns: 5 })); + expect(result.maxRuns).toBe(5); + }); + + it('merges configFile and CLI model multipliers, CLI takes precedence', () => { + const result = validateLogAndLimits(minimalOptions({ + effectiveTokenModelMultipliers: { 'gpt-4': 2 }, + maxModelMultiplier: 'claude-3:3', + })); + expect(result.effectiveTokenModelMultipliers).toEqual({ 'gpt-4': 2, 'claude-3': 3 }); + }); + + it('returns CLI-only multipliers when no config-file multipliers are present', () => { + const result = validateLogAndLimits(minimalOptions({ maxModelMultiplier: 'gpt-4:1.5' })); + expect(result.effectiveTokenModelMultipliers).toEqual({ 'gpt-4': 1.5 }); + }); + + it('returns undefined effectiveTokenModelMultipliers when neither source is set', () => { + const result = validateLogAndLimits(minimalOptions()); + expect(result.effectiveTokenModelMultipliers).toBeUndefined(); + }); + + it('returns valid memoryLimit string', () => { + (parseMemoryLimit as jest.Mock).mockReturnValue({ value: '2g' }); + const result = validateLogAndLimits(minimalOptions()); + expect(result.memoryLimit).toBe('2g'); + }); + + it('logs info message for custom agent image with buildLocal', () => { + (processAgentImageOption as jest.Mock).mockReturnValue({ + agentImage: 'ghcr.io/catthehacker/ubuntu:runner-22.04', + isPreset: false, + infoMessage: 'Using custom agent base image: ghcr.io/catthehacker/ubuntu:runner-22.04', + }); + const result = validateLogAndLimits(minimalOptions({ buildLocal: true })); + expect(result.agentImage).toBe('ghcr.io/catthehacker/ubuntu:runner-22.04'); + expect((logger.info as jest.Mock).mock.calls.some( + (c: unknown[]) => typeof c[0] === 'string' && c[0].includes('ghcr.io/catthehacker/ubuntu:runner-22.04') + )).toBe(true); + }); + + it('passes maxPermissionDenied through', () => { + const result = validateLogAndLimits(minimalOptions({ maxPermissionDenied: 3 })); + expect(result.maxPermissionDenied).toBe(3); + }); + + it('passes modelAliases through', () => { + const aliases = { 'fast': ['gpt-3.5-turbo'] }; + const result = validateLogAndLimits(minimalOptions({ modelAliases: aliases })); + expect(result.modelAliases).toEqual(aliases); + }); +}); + +describe('validateLogAndLimits – validation failures', () => { + it('exits with code 1 for an invalid log level', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ logLevel: 'verbose' }))).toThrow('process.exit called'); + }); + + it('exits when maxEffectiveTokens is not a positive integer (non-integer)', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxEffectiveTokens: '1.5' }))).toThrow('process.exit called'); + }); + + it('exits when maxEffectiveTokens is zero', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxEffectiveTokens: 0 }))).toThrow('process.exit called'); + }); + + it('exits when maxEffectiveTokens is negative', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxEffectiveTokens: -1 }))).toThrow('process.exit called'); + }); + + it('exits when maxAiCredits is zero', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxAiCredits: 0 }))).toThrow('process.exit called'); + }); + + it('exits when maxAiCredits is negative', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxAiCredits: -5 }))).toThrow('process.exit called'); + }); + + it('exits when effectiveTokenDefaultModelMultiplier is zero', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ effectiveTokenDefaultModelMultiplier: 0 }))).toThrow('process.exit called'); + }); + + it('exits when effectiveTokenDefaultModelMultiplier is negative', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ effectiveTokenDefaultModelMultiplier: -1 }))).toThrow('process.exit called'); + }); + + it('exits when maxModelMultiplierCap is zero', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxModelMultiplierCap: 0 }))).toThrow('process.exit called'); + }); + + it('exits when maxModelMultiplierCap is negative', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxModelMultiplierCap: -2 }))).toThrow('process.exit called'); + }); + + it('exits when maxRuns is zero', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxRuns: 0 }))).toThrow('process.exit called'); + }); + + it('exits when maxRuns is a float', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxRuns: '2.5' }))).toThrow('process.exit called'); + }); + + it('exits when maxPermissionDenied is zero', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxPermissionDenied: 0 }))).toThrow('process.exit called'); + }); + + it('exits when maxPermissionDenied is negative', () => { + spyExit(); + expect(() => validateLogAndLimits(minimalOptions({ maxPermissionDenied: -1 }))).toThrow('process.exit called'); + }); + + it('exits when maxModelMultiplier string is malformed', () => { + spyExit(); + // Colons-only, no valid model:number format + expect(() => validateLogAndLimits(minimalOptions({ maxModelMultiplier: 'not-valid-format:xyz' }))).toThrow('process.exit called'); + }); + + it('exits when memoryLimit has an invalid format', () => { + spyExit(); + (parseMemoryLimit as jest.Mock).mockReturnValue({ error: 'Invalid --memory-limit value "invalid-memory".' }); + expect(() => validateLogAndLimits(minimalOptions())).toThrow('process.exit called'); + }); + + it('exits when a custom agentImage is given without --build-local', () => { + spyExit(); + (processAgentImageOption as jest.Mock).mockReturnValue({ + agentImage: 'ghcr.io/catthehacker/ubuntu:runner-22.04', + isPreset: false, + requiresBuildLocal: true, + error: '❌ Custom agent images require --build-local flag', + }); + expect(() => validateLogAndLimits(minimalOptions({ buildLocal: false }))).toThrow('process.exit called'); + }); +}); diff --git a/src/logs/audit-enricher-extra.test.ts b/src/logs/audit-enricher-extra.test.ts new file mode 100644 index 00000000..1da9f25e --- /dev/null +++ b/src/logs/audit-enricher-extra.test.ts @@ -0,0 +1,300 @@ +/** + * Additional tests for audit-enricher.ts covering branches not reached + * by the main audit-enricher.test.ts suite. + */ +import { enrichWithPolicyRules, computeRuleStats, EnrichedLogEntry } from './audit-enricher'; +import { ParsedLogEntry, PolicyManifest, PolicyRule } from '../types'; +import { createLogEntry } from './log-test-fixtures.test-utils'; + +function makeEntry(overrides: Partial = {}): ParsedLogEntry { + return createLogEntry({ + timestamp: 1700000000.0, + host: 'github.com:443', + url: 'github.com:443', + userAgent: 'curl/7.81.0', + domain: 'github.com', + ...overrides, + }); +} + +function makeManifest(rules: PolicyRule[]): PolicyManifest { + return { + version: 1, + generatedAt: '2024-01-01T00:00:00.000Z', + rules, + dangerousPorts: [22, 3306], + dnsServers: ['8.8.8.8'], + sslBumpEnabled: false, + dlpEnabled: false, + hostAccessEnabled: false, + allowHostPorts: null, + }; +} + +const allowRule = (overrides: Partial = {}): PolicyRule => ({ + id: 'allow-both-plain', + order: 1, + action: 'allow', + aclName: 'allowed_domains', + protocol: 'both', + domains: ['.github.com'], + description: 'Allow', + ...overrides, +}); + +const denyAll = (order = 99): PolicyRule => ({ + id: 'deny-default', + order, + action: 'deny', + aclName: 'all', + protocol: 'both', + domains: [], + description: 'Default deny', +}); + +describe('enrichWithPolicyRules – uncovered branches', () => { + describe('domainMatchesRule edge cases', () => { + it('treats dash domain "-" as no-match (returns unknown)', () => { + const manifest = makeManifest([allowRule(), denyAll()]); + const entry = makeEntry({ domain: '-', isAllowed: false }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + // '-' does not match any domain rule; falls through to deny-default (aclName 'all') + expect(enriched.matchedRuleId).toBe('deny-default'); + }); + + it('treats empty domain as no-match, falls through to deny-default', () => { + const manifest = makeManifest([allowRule(), denyAll()]); + const entry = makeEntry({ domain: '', isAllowed: false }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + expect(enriched.matchedRuleId).toBe('deny-default'); + }); + + it('matches domains in a rule whose domains contain regex metacharacters (non-regex aclName)', () => { + // isRegexRule is triggered by domains containing metacharacters, + // even when aclName does not include "regex" + const manifest = makeManifest([ + { + id: 'allow-meta-plain', + order: 1, + action: 'allow', + aclName: 'allowed_custom', + protocol: 'both', + domains: ['^api\\.github\\.com$'], + description: 'Allow via metacharacter pattern', + }, + denyAll(), + ]); + const entry = makeEntry({ domain: 'api.github.com', isAllowed: true }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + expect(enriched.matchedRuleId).toBe('allow-meta-plain'); + }); + + it('skips an invalid regex pattern without throwing', () => { + // The catch block in domainMatchesRule ignores invalid patterns + const manifest = makeManifest([ + { + id: 'allow-bad-regex', + order: 1, + action: 'allow', + aclName: 'allowed_domains_regex', + protocol: 'both', + // First entry is an invalid regex, second is valid and matches + domains: ['[invalid', '^github\\.com$'], + description: 'Allow with bad regex first', + }, + denyAll(), + ]); + const entry = makeEntry({ domain: 'github.com', isAllowed: true }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + expect(enriched.matchedRuleId).toBe('allow-bad-regex'); + }); + + it('does not match when every regex entry is invalid and no fallback rule covers it', () => { + const manifest = makeManifest([ + { + id: 'deny-bad-regex', + order: 1, + action: 'deny', + aclName: 'blocked_domains_regex', + protocol: 'both', + domains: ['[invalid'], + description: 'Deny with bad regex', + }, + denyAll(), + ]); + const entry = makeEntry({ domain: 'github.com', isAllowed: false }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + // bad regex doesn't match, falls through to deny-default + expect(enriched.matchedRuleId).toBe('deny-default'); + }); + }); + + describe('protocol matching', () => { + it('matches HTTP-only rule for plain HTTP request', () => { + const manifest = makeManifest([ + { + id: 'allow-http-only', + order: 1, + action: 'allow', + aclName: 'allowed_http_only', + protocol: 'http', + domains: ['.example.com'], + description: 'Allow HTTP only', + }, + denyAll(), + ]); + const entry = makeEntry({ domain: 'example.com', isHttps: false, method: 'GET', isAllowed: true }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + expect(enriched.matchedRuleId).toBe('allow-http-only'); + }); + + it('does not match HTTP-only rule for HTTPS request', () => { + const manifest = makeManifest([ + { + id: 'allow-http-only', + order: 1, + action: 'allow', + aclName: 'allowed_http_only', + protocol: 'http', + domains: ['.example.com'], + description: 'Allow HTTP only', + }, + denyAll(), + ]); + const entry = makeEntry({ domain: 'example.com', isHttps: true, isAllowed: false }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + // HTTP-only rule skipped (HTTPS); falls through to deny-default + expect(enriched.matchedRuleId).toBe('deny-default'); + }); + + it('returns false for protocol when rule is https but request is HTTP', () => { + const manifest = makeManifest([ + { + id: 'allow-https-only', + order: 1, + action: 'allow', + aclName: 'allowed_https_only', + protocol: 'https', + domains: ['.example.com'], + description: 'Allow HTTPS only', + }, + denyAll(), + ]); + const entry = makeEntry({ domain: 'example.com', isHttps: false, isAllowed: false }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + expect(enriched.matchedRuleId).toBe('deny-default'); + }); + }); + + describe('unknown fallback path', () => { + it('returns unknown matchedRuleId when no rule matches the entry', () => { + // A manifest with only an allow rule for a specific domain and no deny-all rule + const manifest = makeManifest([ + allowRule({ domains: ['.github.com'] }), + // No deny-default (aclName: 'all') rule here + ]); + // A denied entry for a domain that doesn't match the allow rule + const entry = makeEntry({ domain: 'unmatched.example.com', isAllowed: false }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + expect(enriched.matchedRuleId).toBe('unknown'); + expect(enriched.matchReason).toContain('Denied'); + }); + + it('returns unknown matchedRuleId for an allowed entry with no matching allow rule', () => { + const manifest = makeManifest([ + // Deny rule first, no allow rule that covers this domain + { + id: 'deny-blocked', + order: 1, + action: 'deny', + aclName: 'blocked_domains', + protocol: 'both', + domains: ['.blocked.com'], + description: 'Deny blocked', + }, + ]); + const entry = makeEntry({ domain: 'unmatched.com', isAllowed: true }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + expect(enriched.matchedRuleId).toBe('unknown'); + expect(enriched.matchReason).toContain('Allowed'); + }); + }); + + describe('rule action mismatch skips rule', () => { + it('skips a domain-matching rule whose action does not match the observed outcome', () => { + // The allow rule matches the domain, but the entry was *denied* — action mismatch + // so the loop continues and falls through to deny-default + const manifest = makeManifest([allowRule(), denyAll()]); + const entry = makeEntry({ domain: 'github.com', isAllowed: false }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + expect(enriched.matchedRuleId).toBe('deny-default'); + }); + }); + + describe('rules with empty domains list are skipped', () => { + it('skips non-all rules that have no domain entries', () => { + const manifest = makeManifest([ + { + id: 'deny-unsafe-ports', + order: 1, + action: 'deny', + aclName: 'unsafe_ports', + protocol: 'both', + domains: [], // port-based rule — no domains + description: 'Deny unsafe ports', + }, + denyAll(), + ]); + const entry = makeEntry({ domain: 'github.com', isAllowed: false }); + const [enriched] = enrichWithPolicyRules([entry], manifest); + // Port rule skipped (no domains); falls to deny-default + expect(enriched.matchedRuleId).toBe('deny-default'); + }); + }); +}); + +describe('computeRuleStats – uncovered branches', () => { + it('skips entries with url "error:transaction-end-before-headers"', () => { + const manifest = makeManifest([allowRule(), denyAll()]); + const entries: EnrichedLogEntry[] = [ + { + ...makeEntry({ url: 'error:transaction-end-before-headers' }), + matchedRuleId: 'allow-both-plain', + matchReason: '', + }, + ]; + const stats = computeRuleStats(entries, manifest); + // Despite having a matchedRuleId, the entry is skipped — 0 hits + const allowStats = stats.find(r => r.ruleId === 'allow-both-plain'); + expect(allowStats?.hits).toBe(0); + }); + + it('includes "unknown" entry in stats when there are unknown-matched hits', () => { + const manifest = makeManifest([allowRule(), denyAll()]); + const entries: EnrichedLogEntry[] = [ + { + ...makeEntry({ domain: 'unmatched.com', isAllowed: false }), + matchedRuleId: 'unknown', + matchReason: 'Denied (rule not identified)', + }, + ]; + const stats = computeRuleStats(entries, manifest); + const unknownStats = stats.find(r => r.ruleId === 'unknown'); + expect(unknownStats).toBeDefined(); + expect(unknownStats?.hits).toBe(1); + expect(unknownStats?.action).toBe('deny'); + }); + + it('does not include "unknown" row when there are no unknown hits', () => { + const manifest = makeManifest([allowRule(), denyAll()]); + const entries: EnrichedLogEntry[] = [ + { + ...makeEntry({ domain: 'github.com' }), + matchedRuleId: 'allow-both-plain', + matchReason: 'Allow', + }, + ]; + const stats = computeRuleStats(entries, manifest); + expect(stats.find(r => r.ruleId === 'unknown')).toBeUndefined(); + }); +}); diff --git a/src/services/agent-volumes/docker-host-staging.test.ts b/src/services/agent-volumes/docker-host-staging.test.ts new file mode 100644 index 00000000..1e2697c2 --- /dev/null +++ b/src/services/agent-volumes/docker-host-staging.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for docker-host-staging.ts. + * Covers shouldUseDockerHostStaging, stageHostFile, and extractCommandBinaryName. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + shouldUseDockerHostStaging, + stageHostFile, + extractCommandBinaryName, + getDockerHostStageRoot, +} from './docker-host-staging'; +import { WrapperConfig } from '../../types'; + +function makeConfig(overrides: Partial = {}): WrapperConfig { + return { + allowDomains: 'example.com', + agentCommand: 'echo test', + workDir: '/tmp/awf-test', + ...overrides, + } as WrapperConfig; +} + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-dhs-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('shouldUseDockerHostStaging', () => { + it('returns false when prefix is undefined', () => { + expect(shouldUseDockerHostStaging(undefined)).toBe(false); + }); + + it('returns false when prefix is an empty string', () => { + expect(shouldUseDockerHostStaging('')).toBe(false); + }); + + it('returns false when prefix is whitespace only', () => { + expect(shouldUseDockerHostStaging(' ')).toBe(false); + }); + + it('returns true for exactly "/tmp"', () => { + expect(shouldUseDockerHostStaging('/tmp')).toBe(true); + }); + + it('returns true for "/tmp/" (trailing slash variant)', () => { + expect(shouldUseDockerHostStaging('/tmp/')).toBe(true); + }); + + it('returns true for "/tmp/sub-path"', () => { + expect(shouldUseDockerHostStaging('/tmp/sub-path')).toBe(true); + }); + + it('returns false for paths outside /tmp', () => { + expect(shouldUseDockerHostStaging('/var/runner')).toBe(false); + expect(shouldUseDockerHostStaging('/home/runner')).toBe(false); + }); + + it('returns false for a prefix that is a subdirectory of something that starts with /tmp in its name but is not /tmp', () => { + // "/tmpfoo" is not /tmp and does not start with /tmp/ + expect(shouldUseDockerHostStaging('/tmpfoo')).toBe(false); + }); + + it('returns true for prefix without leading slash when it resolves to /tmp', () => { + // normalizeDockerHostPathPrefix adds the leading slash + expect(shouldUseDockerHostStaging('tmp')).toBe(true); + }); +}); + +describe('getDockerHostStageRoot', () => { + it('uses workDir as stageRoot when prefix is not a /tmp path', () => { + const config = makeConfig({ workDir: tmpDir, dockerHostPathPrefix: '/var/runner' }); + const stageRoot = getDockerHostStageRoot(config); + expect(stageRoot).toContain(tmpDir); + expect(fs.existsSync(stageRoot)).toBe(true); + }); + + it('uses normalizedPrefix as stageRoot for /tmp-based prefixes', () => { + const config = makeConfig({ workDir: tmpDir, dockerHostPathPrefix: tmpDir }); + const stageRoot = getDockerHostStageRoot(config); + expect(stageRoot).toContain(tmpDir); + expect(fs.existsSync(stageRoot)).toBe(true); + }); + + it('creates the stage root directory', () => { + const config = makeConfig({ workDir: tmpDir }); + const stageRoot = getDockerHostStageRoot(config); + expect(fs.existsSync(stageRoot)).toBe(true); + expect(fs.statSync(stageRoot).isDirectory()).toBe(true); + }); +}); + +describe('stageHostFile', () => { + it('returns undefined when the source path does not exist', () => { + const config = makeConfig({ workDir: tmpDir }); + const result = stageHostFile(config, '/nonexistent/path/file.txt', 'etc/file.txt'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when source path is a directory, not a file', () => { + const config = makeConfig({ workDir: tmpDir }); + // Pass a directory path as the source + const result = stageHostFile(config, tmpDir, 'etc/notfile.txt'); + expect(result).toBeUndefined(); + }); + + it('copies the file to the stage root and returns the destination path', () => { + const srcFile = path.join(tmpDir, 'source.txt'); + fs.writeFileSync(srcFile, 'hello staging'); + + const config = makeConfig({ workDir: tmpDir }); + const result = stageHostFile(config, srcFile, 'etc/source.txt'); + expect(result).toBeDefined(); + expect(fs.readFileSync(result!, 'utf8')).toBe('hello staging'); + }); + + it('returns undefined when relativeTargetPath would escape the stage root (path traversal)', () => { + const srcFile = path.join(tmpDir, 'source.txt'); + fs.writeFileSync(srcFile, 'data'); + const config = makeConfig({ workDir: tmpDir }); + const result = stageHostFile(config, srcFile, '../../etc/passwd'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when relativeTargetPath normalizes to empty string', () => { + const srcFile = path.join(tmpDir, 'source.txt'); + fs.writeFileSync(srcFile, 'data'); + const config = makeConfig({ workDir: tmpDir }); + // A relative path that after stripping leading slashes is empty should be rejected + const result = stageHostFile(config, srcFile, '/'); + expect(result).toBeUndefined(); + }); + + it('creates nested directories as needed within the stage root', () => { + const srcFile = path.join(tmpDir, 'cert.pem'); + fs.writeFileSync(srcFile, 'cert-data'); + const config = makeConfig({ workDir: tmpDir }); + const result = stageHostFile(config, srcFile, 'ssl/certs/cert.pem'); + expect(result).toBeDefined(); + expect(fs.existsSync(result!)).toBe(true); + }); + + it('applies the specified file mode', () => { + const srcFile = path.join(tmpDir, 'secret.txt'); + fs.writeFileSync(srcFile, 'secret'); + const config = makeConfig({ workDir: tmpDir }); + const result = stageHostFile(config, srcFile, 'secrets/secret.txt', 0o600); + expect(result).toBeDefined(); + const mode = fs.statSync(result!).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); + +describe('extractCommandBinaryName', () => { + it('returns the binary name for a simple command', () => { + expect(extractCommandBinaryName('curl https://example.com')).toBe('curl'); + }); + + it('returns the binary name for a command with a path prefix', () => { + expect(extractCommandBinaryName('/usr/bin/curl -s https://example.com')).toBe('curl'); + }); + + it('returns undefined for an empty string', () => { + expect(extractCommandBinaryName('')).toBeUndefined(); + }); + + it('returns undefined for a whitespace-only string', () => { + expect(extractCommandBinaryName(' ')).toBeUndefined(); + }); + + it('returns undefined when the binary basename contains unsafe characters', () => { + // A binary like "../../malicious" has basename "malicious" which IS safe, + // but we test the regex: a name with a null byte or shell metachar is unsafe + expect(extractCommandBinaryName('/bin/my;cmd arg')).toBeUndefined(); + }); + + it('allows dots and hyphens in binary names', () => { + expect(extractCommandBinaryName('node.js-runner --flag')).toBe('node.js-runner'); + }); + + it('handles command without arguments', () => { + expect(extractCommandBinaryName('ls')).toBe('ls'); + }); +}); From 1ffad81b4b91e196568658b819a3a7d8acc4c243 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 17 Jun 2026 08:10:03 -0700 Subject: [PATCH 2/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/logs/audit-enricher-extra.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/logs/audit-enricher-extra.test.ts b/src/logs/audit-enricher-extra.test.ts index 1da9f25e..cd58a400 100644 --- a/src/logs/audit-enricher-extra.test.ts +++ b/src/logs/audit-enricher-extra.test.ts @@ -54,7 +54,7 @@ const denyAll = (order = 99): PolicyRule => ({ describe('enrichWithPolicyRules – uncovered branches', () => { describe('domainMatchesRule edge cases', () => { - it('treats dash domain "-" as no-match (returns unknown)', () => { + it('treats dash domain "-" as no-match, falls through to deny-default', () => { const manifest = makeManifest([allowRule(), denyAll()]); const entry = makeEntry({ domain: '-', isAllowed: false }); const [enriched] = enrichWithPolicyRules([entry], manifest); From a1fefa85dac2f221b83c5141b775fd793b6355c2 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 17 Jun 2026 08:11:19 -0700 Subject: [PATCH 3/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/logs/audit-enricher-extra.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/logs/audit-enricher-extra.test.ts b/src/logs/audit-enricher-extra.test.ts index cd58a400..5d5c712c 100644 --- a/src/logs/audit-enricher-extra.test.ts +++ b/src/logs/audit-enricher-extra.test.ts @@ -109,7 +109,7 @@ describe('enrichWithPolicyRules – uncovered branches', () => { expect(enriched.matchedRuleId).toBe('allow-bad-regex'); }); - it('does not match when every regex entry is invalid and no fallback rule covers it', () => { + it('falls through to deny-default when every regex entry is invalid', () => { const manifest = makeManifest([ { id: 'deny-bad-regex', From a53de75053968fbc708ae6764fffa6cc1b678529 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 17 Jun 2026 08:11:35 -0700 Subject: [PATCH 4/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/commands/validators/log-and-limits.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/validators/log-and-limits.test.ts b/src/commands/validators/log-and-limits.test.ts index 7b67fb37..db7965b4 100644 --- a/src/commands/validators/log-and-limits.test.ts +++ b/src/commands/validators/log-and-limits.test.ts @@ -23,7 +23,7 @@ jest.mock('../../option-parsers', () => { const actual = jest.requireActual('../../option-parsers'); return { ...actual, - parseMemoryLimit: jest.fn().mockReturnValue({ value: undefined }), + parseMemoryLimit: jest.fn().mockReturnValue({ value: '6g' }), }; }); From 236052f627db42c143698274bc044eadb8830f55 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 17 Jun 2026 08:11:45 -0700 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/commands/validators/log-and-limits.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/validators/log-and-limits.test.ts b/src/commands/validators/log-and-limits.test.ts index db7965b4..a150899c 100644 --- a/src/commands/validators/log-and-limits.test.ts +++ b/src/commands/validators/log-and-limits.test.ts @@ -60,7 +60,7 @@ function spyExit(): jest.SpyInstance { afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); - (parseMemoryLimit as jest.Mock).mockReturnValue({ value: undefined }); + (parseMemoryLimit as jest.Mock).mockReturnValue({ value: '6g' }); (processAgentImageOption as jest.Mock).mockReturnValue({ agentImage: 'default', isPreset: true }); }); From f8987b25414326d7e8b7f59b6dda4b1e46178c39 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 17 Jun 2026 08:12:00 -0700 Subject: [PATCH 6/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/commands/validators/log-and-limits.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/validators/log-and-limits.test.ts b/src/commands/validators/log-and-limits.test.ts index a150899c..cbe05bdb 100644 --- a/src/commands/validators/log-and-limits.test.ts +++ b/src/commands/validators/log-and-limits.test.ts @@ -47,6 +47,7 @@ function minimalOptions(overrides: Record = {}): Record Date: Wed, 17 Jun 2026 08:12:20 -0700 Subject: [PATCH 7/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/commands/validators/log-and-limits.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/validators/log-and-limits.test.ts b/src/commands/validators/log-and-limits.test.ts index cbe05bdb..8307cd82 100644 --- a/src/commands/validators/log-and-limits.test.ts +++ b/src/commands/validators/log-and-limits.test.ts @@ -71,7 +71,7 @@ describe('validateLogAndLimits – success paths', () => { expect(result.logLevel).toBe('info'); expect(result.maxEffectiveTokens).toBeUndefined(); expect(result.maxRuns).toBeUndefined(); - expect(result.memoryLimit).toBeUndefined(); + expect(result.memoryLimit).toBe('6g'); expect(result.agentImage).toBe('default'); });