From ccc32da84b18153129523184ed7448092accce78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:05:12 +0000 Subject: [PATCH 1/7] Initial plan From ef81ba7411683e79ac09b96094e35970b2e39afb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:08:37 +0000 Subject: [PATCH 2/7] docs: initial plan for secret exposure mitigations Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- package-lock.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/package-lock.json b/package-lock.json index 7136e72f..a0e96dab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3210,6 +3211,7 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3283,6 +3285,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -3763,6 +3766,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4089,6 +4093,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4370,6 +4375,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4606,6 +4612,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5609,6 +5616,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7689,6 +7697,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7845,6 +7854,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From ed74c819c8377fc2c0a23db69fcaa7bf35149c5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:15:48 +0000 Subject: [PATCH 3/7] fix: restrict /proc/self/environ and docker-compose.yml secret exposure Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/entrypoint.sh | 75 ++++++++++ .../agent/one-shot-token/one-shot-token.c | 132 +++++++++++++++++- src/cli-workflow.test.ts | 8 ++ src/cli-workflow.ts | 6 + src/cli.ts | 2 + src/docker-manager.test.ts | 98 ++++++++++++- src/docker-manager.ts | 67 +++++++++ 7 files changed, 383 insertions(+), 5 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 77a25f10..2c289817 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -389,9 +389,50 @@ AWFEOF LD_PRELOAD_CMD="export LD_PRELOAD=${ONE_SHOT_TOKEN_LIB};" fi + # Scrub sensitive tokens from the environment before exec to prevent + # /proc/self/environ exposure. Write values to a cache file so the + # LD_PRELOAD library can still serve them via getenv(). + SCRUB_CMD="" + if [ -n "${ONE_SHOT_TOKEN_LIB}" ]; then + TOKEN_CACHE_FILE="/tmp/.awf-token-cache-$$" + # Write sensitive token values to cache file (inside chroot perspective) + # The LD_PRELOAD constructor reads this file and deletes it immediately + : > "/host${TOKEN_CACHE_FILE}" + chmod 600 "/host${TOKEN_CACHE_FILE}" + # Use AWF_ONE_SHOT_TOKENS if set, otherwise use defaults + if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then + SENSITIVE_TOKENS="${AWF_ONE_SHOT_TOKENS}" + else + SENSITIVE_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" + fi + IFS=',' read -ra TOKEN_NAMES <<< "${SENSITIVE_TOKENS}" + for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do + TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) # trim whitespace + if [ -n "$TOKEN_NAME" ]; then + TOKEN_VALUE=$(eval echo "\${${TOKEN_NAME}:-}") + if [ -n "$TOKEN_VALUE" ]; then + echo "${TOKEN_NAME}=${TOKEN_VALUE}" >> "/host${TOKEN_CACHE_FILE}" + echo "[entrypoint] Token ${TOKEN_NAME} written to cache file and will be scrubbed from environ" + fi + fi + done + chown "$(id -u awfuser):$(id -g awfuser)" "/host${TOKEN_CACHE_FILE}" 2>/dev/null || true + SCRUB_CMD="export AWF_TOKEN_CACHE_FILE=${TOKEN_CACHE_FILE};" + # Unset sensitive vars so they don't appear in /proc/self/environ of exec'd process + for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do + TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) + if [ -n "$TOKEN_NAME" ]; then + unset "$TOKEN_NAME" 2>/dev/null || true + fi + done + # Also add cache file cleanup to the exit trap + CLEANUP_CMD="${CLEANUP_CMD}; rm -f ${TOKEN_CACHE_FILE} 2>/dev/null || true" + fi + exec chroot /host /bin/bash -c " cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / trap '${CLEANUP_CMD}' EXIT + ${SCRUB_CMD} ${LD_PRELOAD_CMD} exec capsh --drop=${CAPS_TO_DROP} --user=${HOST_USER} -- -c 'exec ${SCRIPT_FILE}' " @@ -409,5 +450,39 @@ else # Enable one-shot token protection - tokens are cached in memory and # unset from the environment so /proc/self/environ is cleared export LD_PRELOAD=/usr/local/lib/one-shot-token.so + + # Scrub sensitive tokens from the environment before exec to prevent + # /proc/self/environ exposure. Write values to a cache file so the + # LD_PRELOAD library can still serve them via getenv(). + TOKEN_CACHE_FILE="/tmp/.awf-token-cache-$$" + : > "${TOKEN_CACHE_FILE}" + chmod 600 "${TOKEN_CACHE_FILE}" + # Use AWF_ONE_SHOT_TOKENS if set, otherwise use defaults + if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then + SENSITIVE_TOKENS="${AWF_ONE_SHOT_TOKENS}" + else + SENSITIVE_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" + fi + IFS=',' read -ra TOKEN_NAMES <<< "${SENSITIVE_TOKENS}" + for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do + TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) # trim whitespace + if [ -n "$TOKEN_NAME" ]; then + TOKEN_VALUE=$(eval echo "\${${TOKEN_NAME}:-}") + if [ -n "$TOKEN_VALUE" ]; then + echo "${TOKEN_NAME}=${TOKEN_VALUE}" >> "${TOKEN_CACHE_FILE}" + echo "[entrypoint] Token ${TOKEN_NAME} written to cache file and will be scrubbed from environ" + fi + fi + done + chown "$(id -u awfuser):$(id -g awfuser)" "${TOKEN_CACHE_FILE}" 2>/dev/null || true + export AWF_TOKEN_CACHE_FILE="${TOKEN_CACHE_FILE}" + # Unset sensitive vars so they don't appear in /proc/self/environ of exec'd process + for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do + TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) + if [ -n "$TOKEN_NAME" ]; then + unset "$TOKEN_NAME" 2>/dev/null || true + fi + done + exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" fi diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index 3b8cda82..d35ca1ff 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -1,13 +1,21 @@ /** * One-Shot Token LD_PRELOAD Library * - * Intercepts getenv() calls for sensitive token environment variables. - * On first access, caches the value in memory and unsets from environment. - * Subsequent calls return the cached value, so the process can read tokens - * multiple times while /proc/self/environ no longer exposes them. + * Protects sensitive token environment variables from exposure via + * /proc/self/environ and limits access via getenv(). + * + * When loaded, the library constructor reads cached token values from + * AWF_TOKEN_CACHE_FILE (written by entrypoint.sh), populates an in-memory + * cache, and immediately deletes the file. The sensitive variables are + * never present in the process environment, so /proc/self/environ is clean. + * Subsequent getenv() calls return the cached values from memory. + * + * Fallback: If no cache file is found, tokens are read from the environment + * on first getenv() call, cached, and unset (original behavior). * * Configuration: * AWF_ONE_SHOT_TOKENS - Comma-separated list of token names to protect + * AWF_TOKEN_CACHE_FILE - Path to the token cache file (set by entrypoint.sh) * If not set, uses built-in defaults * * Compile: gcc -shared -fPIC -o one-shot-token.so one-shot-token.c -ldl @@ -20,6 +28,7 @@ #include #include #include +#include #include /* Default sensitive token environment variable names */ @@ -183,6 +192,121 @@ static void init_token_list(void) { tokens_initialized = 1; } + +/** + * Load cached token values from AWF_TOKEN_CACHE_FILE. + * + * The file format is one NAME=VALUE per line. After reading, the file + * is deleted immediately to minimize the exposure window. + * + * Must be called with token_mutex held and after init_token_list(). + */ +static void load_token_cache_file(void) { + const char *cache_path = real_getenv("AWF_TOKEN_CACHE_FILE"); + if (cache_path == NULL || cache_path[0] == '\0') { + return; + } + + FILE *f = fopen(cache_path, "r"); + if (f == NULL) { + fprintf(stderr, "[one-shot-token] WARNING: Could not open token cache file: %s\n", cache_path); + return; + } + + char line[8192]; + int loaded = 0; + while (fgets(line, sizeof(line), f) != NULL) { + /* Strip trailing newline */ + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') { + line[len - 1] = '\0'; + len--; + } + + /* Find the '=' separator */ + char *eq = strchr(line, '='); + if (eq == NULL || eq == line) continue; + + *eq = '\0'; + const char *name = line; + const char *value = eq + 1; + + /* Find if this name matches a sensitive token */ + int idx = get_token_index(name); + if (idx >= 0 && !token_accessed[idx]) { + token_cache[idx] = strdup(value); + if (token_cache[idx] != NULL) { + token_accessed[idx] = 1; + loaded++; + fprintf(stderr, "[one-shot-token] Loaded cached token %s (value: %s)\n", + name, format_token_value(token_cache[idx])); + } + } + } + + fclose(f); + + /* Delete the cache file immediately to minimize exposure */ + if (unlink(cache_path) == 0) { + fprintf(stderr, "[one-shot-token] Token cache file deleted: %s\n", cache_path); + } else { + fprintf(stderr, "[one-shot-token] WARNING: Could not delete token cache file: %s\n", cache_path); + } + + /* Also remove AWF_TOKEN_CACHE_FILE from environ */ + unsetenv("AWF_TOKEN_CACHE_FILE"); + + if (loaded > 0) { + fprintf(stderr, "[one-shot-token] Loaded %d token(s) from cache file\n", loaded); + } +} + +/** + * Library constructor - runs when the library is loaded (before main()). + * + * If AWF_TOKEN_CACHE_FILE is set (by entrypoint.sh), loads cached token + * values from the file and deletes it. The sensitive variables are never + * present in /proc/self/environ because entrypoint.sh unsets them before + * exec. + * + * If no cache file exists, tokens remain in the environment and will be + * cached + unset on first getenv() call (original fallback behavior). + */ +__attribute__((constructor)) +static void one_shot_token_init(void) { + /* Initialize the real getenv pointer first */ + init_real_getenv_once(); + + pthread_mutex_lock(&token_mutex); + if (!tokens_initialized) { + init_token_list(); + } + + /* Load tokens from cache file if available (set by entrypoint.sh) */ + load_token_cache_file(); + + /* Eagerly cache any remaining sensitive tokens still in the environment + * (fallback for when no cache file was used) */ + for (int i = 0; i < num_tokens; i++) { + if (!token_accessed[i]) { + char *value = real_getenv(sensitive_tokens[i]); + if (value != NULL) { + token_cache[i] = strdup(value); + if (token_cache[i] != NULL) { + unsetenv(sensitive_tokens[i]); + fprintf(stderr, "[one-shot-token] Token %s eagerly cached and scrubbed from environ (value: %s)\n", + sensitive_tokens[i], format_token_value(token_cache[i])); + } + token_accessed[i] = 1; + } + } + } + pthread_mutex_unlock(&token_mutex); + + fprintf(stderr, "[one-shot-token] Library initialized: %d token(s) protected, /proc/self/environ scrubbed\n", + num_tokens); +} + /* Ensure real_getenv is initialized (thread-safe) */ static void init_real_getenv(void) { pthread_once(&getenv_init_once, init_real_getenv_once); diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index 02490c66..c536607d 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -35,6 +35,9 @@ describe('runMainWorkflow', () => { startContainers: jest.fn().mockImplementation(async () => { callOrder.push('startContainers'); }), + redactComposeSecrets: jest.fn().mockImplementation(() => { + callOrder.push('redactComposeSecrets'); + }), runAgentCommand: jest.fn().mockImplementation(async () => { callOrder.push('runAgentCommand'); return { exitCode: 0 }; @@ -55,6 +58,7 @@ describe('runMainWorkflow', () => { 'setupHostIptables', 'writeConfigs', 'startContainers', + 'redactComposeSecrets', 'runAgentCommand', 'performCleanup', ]); @@ -79,6 +83,9 @@ describe('runMainWorkflow', () => { startContainers: jest.fn().mockImplementation(async () => { callOrder.push('startContainers'); }), + redactComposeSecrets: jest.fn().mockImplementation(() => { + callOrder.push('redactComposeSecrets'); + }), runAgentCommand: jest.fn().mockImplementation(async () => { callOrder.push('runAgentCommand'); return { exitCode: 42 }; @@ -100,6 +107,7 @@ describe('runMainWorkflow', () => { 'setupHostIptables', 'writeConfigs', 'startContainers', + 'redactComposeSecrets', 'runAgentCommand', 'performCleanup', ]); diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 62ad1511..66019888 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -5,6 +5,7 @@ export interface WorkflowDependencies { setupHostIptables: (squidIp: string, port: number, dnsServers: string[]) => Promise; writeConfigs: (config: WrapperConfig) => Promise; startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise; + redactComposeSecrets: (workDir: string) => void; runAgentCommand: ( workDir: string, allowedDomains: string[], @@ -52,6 +53,11 @@ export async function runMainWorkflow( // Step 2: Start containers await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull); + + // Step 2.5: Redact secrets from docker-compose.yml after containers start + // This prevents exposure via /host/tmp/awf-*/docker-compose.yml + dependencies.redactComposeSecrets(config.workDir); + onContainersStarted?.(); // Step 3: Wait for agent to complete diff --git a/src/cli.ts b/src/cli.ts index c0323a10..052a35fc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { runAgentCommand, stopContainers, cleanup, + redactComposeSecrets, } from './docker-manager'; import { ensureFirewallNetwork, @@ -1023,6 +1024,7 @@ program setupHostIptables, writeConfigs, startContainers, + redactComposeSecrets, runAgentCommand, }, { diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 63aa7490..5c591eb6 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1,4 +1,4 @@ -import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE } from './docker-manager'; +import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE, redactComposeSecrets, SENSITIVE_ENV_NAMES } from './docker-manager'; import { WrapperConfig } from './types'; import * as fs from 'fs'; import * as path from 'path'; @@ -1812,4 +1812,100 @@ describe('docker-manager', () => { await expect(cleanup(nonExistentDir, false)).resolves.not.toThrow(); }); }); + + describe('redactComposeSecrets', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-redact-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should redact sensitive environment variable values in docker-compose.yml', () => { + const composePath = path.join(tmpDir, 'docker-compose.yml'); + const content = [ + 'services:', + ' agent:', + ' environment:', + ' - GITHUB_TOKEN=ghp_abc123secret456', + ' - ANTHROPIC_API_KEY=sk-ant-secret789', + ' - HOME=/root', + ' - PATH=/usr/bin', + ].join('\n'); + fs.writeFileSync(composePath, content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(composePath, 'utf8'); + expect(result).toContain('GITHUB_TOKEN=***REDACTED***'); + expect(result).toContain('ANTHROPIC_API_KEY=***REDACTED***'); + expect(result).not.toContain('ghp_abc123secret456'); + expect(result).not.toContain('sk-ant-secret789'); + // Non-sensitive vars should remain + expect(result).toContain('HOME=/root'); + expect(result).toContain('PATH=/usr/bin'); + }); + + it('should handle YAML key-value format', () => { + const composePath = path.join(tmpDir, 'docker-compose.yml'); + const content = [ + 'services:', + ' agent:', + ' environment:', + ' GITHUB_TOKEN: ghp_abc123secret456', + ' GH_TOKEN: gho_another_secret', + ].join('\n'); + fs.writeFileSync(composePath, content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(composePath, 'utf8'); + expect(result).toContain('GITHUB_TOKEN: ***REDACTED***'); + expect(result).toContain('GH_TOKEN: ***REDACTED***'); + expect(result).not.toContain('ghp_abc123secret456'); + expect(result).not.toContain('gho_another_secret'); + }); + + it('should not modify file when no sensitive vars present', () => { + const composePath = path.join(tmpDir, 'docker-compose.yml'); + const content = [ + 'services:', + ' agent:', + ' environment:', + ' - HOME=/root', + ' - PATH=/usr/bin', + ].join('\n'); + fs.writeFileSync(composePath, content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(composePath, 'utf8'); + expect(result).toBe(content); + }); + + it('should handle missing docker-compose.yml gracefully', () => { + // Should not throw + expect(() => redactComposeSecrets(tmpDir)).not.toThrow(); + }); + + it('should redact all known sensitive env names', () => { + const composePath = path.join(tmpDir, 'docker-compose.yml'); + const lines = Array.from(SENSITIVE_ENV_NAMES).map( + name => ` - ${name}=secret_value_for_${name}` + ); + const content = ['services:', ' agent:', ' environment:', ...lines].join('\n'); + fs.writeFileSync(composePath, content); + + redactComposeSecrets(tmpDir); + + const result = fs.readFileSync(composePath, 'utf8'); + for (const name of SENSITIVE_ENV_NAMES) { + expect(result).toContain(`${name}=***REDACTED***`); + expect(result).not.toContain(`secret_value_for_${name}`); + } + }); + }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 18481fe9..36532c1d 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -858,6 +858,73 @@ async function checkSquidLogs(workDir: string, proxyLogsDir?: string): Promise<{ } } +/** + * Environment variable names that contain sensitive tokens/secrets. + * These are redacted from docker-compose.yml after containers start + * to prevent exposure via the /host mount. + * + * This list must stay aligned with DEFAULT_SENSITIVE_TOKENS in: + * - containers/agent/one-shot-token/one-shot-token.c + * - containers/agent/entrypoint.sh (SENSITIVE_TOKENS variable) + */ +export const SENSITIVE_ENV_NAMES = new Set([ + 'COPILOT_GITHUB_TOKEN', + 'GITHUB_TOKEN', + 'GH_TOKEN', + 'GITHUB_API_TOKEN', + 'GITHUB_PAT', + 'GH_ACCESS_TOKEN', + 'GITHUB_PERSONAL_ACCESS_TOKEN', + 'OPENAI_API_KEY', + 'OPENAI_KEY', + 'ANTHROPIC_API_KEY', + 'CLAUDE_API_KEY', + 'CODEX_API_KEY', +]); + +/** + * Redacts sensitive environment variable values in docker-compose.yml + * after containers have started. This prevents exposure of secrets via + * the /host mount point (e.g., reading the compose file from within the container). + * + * @param workDir - Working directory containing docker-compose.yml + */ +export function redactComposeSecrets(workDir: string): void { + const composePath = path.join(workDir, 'docker-compose.yml'); + if (!fs.existsSync(composePath)) { + logger.debug('No docker-compose.yml found to redact'); + return; + } + + try { + let content = fs.readFileSync(composePath, 'utf8'); + let redactedCount = 0; + + for (const name of SENSITIVE_ENV_NAMES) { + // Match YAML environment entries like: + // - GITHUB_TOKEN=ghp_abc123 + // GITHUB_TOKEN: ghp_abc123 + // Replace the value with ***REDACTED*** + const envLinePattern = new RegExp( + `(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(=|:\\s*)(.+)`, + 'g' + ); + const newContent = content.replace(envLinePattern, `$1$2***REDACTED***`); + if (newContent !== content) { + redactedCount++; + content = newContent; + } + } + + if (redactedCount > 0) { + fs.writeFileSync(composePath, content); + logger.info(`Redacted ${redactedCount} sensitive value(s) from docker-compose.yml`); + } + } catch (error) { + logger.warn('Could not redact docker-compose.yml secrets:', error); + } +} + /** * Starts Docker Compose services * @param workDir - Working directory containing Docker Compose config From a92d57abe533f5ceb020d7e721b2897aa0bb05a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:18:35 +0000 Subject: [PATCH 4/7] fix: address code review feedback for secret exposure mitigations Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/entrypoint.sh | 12 +++++------ containers/agent/one-shot-token/README.md | 20 +++++++++++++++++-- .../agent/one-shot-token/one-shot-token.c | 7 +++---- src/docker-manager.ts | 2 +- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 2c289817..a512dcf0 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -397,8 +397,8 @@ AWFEOF TOKEN_CACHE_FILE="/tmp/.awf-token-cache-$$" # Write sensitive token values to cache file (inside chroot perspective) # The LD_PRELOAD constructor reads this file and deletes it immediately - : > "/host${TOKEN_CACHE_FILE}" - chmod 600 "/host${TOKEN_CACHE_FILE}" + # Use umask to create the file with restricted permissions atomically + (umask 077 && : > "/host${TOKEN_CACHE_FILE}") # Use AWF_ONE_SHOT_TOKENS if set, otherwise use defaults if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then SENSITIVE_TOKENS="${AWF_ONE_SHOT_TOKENS}" @@ -409,7 +409,7 @@ AWFEOF for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) # trim whitespace if [ -n "$TOKEN_NAME" ]; then - TOKEN_VALUE=$(eval echo "\${${TOKEN_NAME}:-}") + TOKEN_VALUE=$(printenv "$TOKEN_NAME" 2>/dev/null || true) if [ -n "$TOKEN_VALUE" ]; then echo "${TOKEN_NAME}=${TOKEN_VALUE}" >> "/host${TOKEN_CACHE_FILE}" echo "[entrypoint] Token ${TOKEN_NAME} written to cache file and will be scrubbed from environ" @@ -455,8 +455,8 @@ else # /proc/self/environ exposure. Write values to a cache file so the # LD_PRELOAD library can still serve them via getenv(). TOKEN_CACHE_FILE="/tmp/.awf-token-cache-$$" - : > "${TOKEN_CACHE_FILE}" - chmod 600 "${TOKEN_CACHE_FILE}" + # Use umask to create the file with restricted permissions atomically + (umask 077 && : > "${TOKEN_CACHE_FILE}") # Use AWF_ONE_SHOT_TOKENS if set, otherwise use defaults if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then SENSITIVE_TOKENS="${AWF_ONE_SHOT_TOKENS}" @@ -467,7 +467,7 @@ else for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) # trim whitespace if [ -n "$TOKEN_NAME" ]; then - TOKEN_VALUE=$(eval echo "\${${TOKEN_NAME}:-}") + TOKEN_VALUE=$(printenv "$TOKEN_NAME" 2>/dev/null || true) if [ -n "$TOKEN_VALUE" ]; then echo "${TOKEN_NAME}=${TOKEN_VALUE}" >> "${TOKEN_CACHE_FILE}" echo "[entrypoint] Token ${TOKEN_NAME} written to cache file and will be scrubbed from environ" diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index db2d21c0..69eb2d77 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -2,7 +2,11 @@ ## Overview -The one-shot token library is an `LD_PRELOAD` shared library that provides **cached access** to sensitive environment variables containing GitHub, OpenAI, Anthropic/Claude, and Codex API tokens. When a process reads a protected token via `getenv()`, the library caches the value in memory and immediately unsets the environment variable. Subsequent `getenv()` calls return the cached value, allowing the process to read tokens multiple times while `/proc/self/environ` is cleared. +The one-shot token library is an `LD_PRELOAD` shared library that provides **cached access** to sensitive environment variables containing GitHub, OpenAI, Anthropic/Claude, and Codex API tokens. On library load, a constructor eagerly caches all sensitive tokens and removes them from the process environment, ensuring `/proc/self/environ` never exposes secrets to user code. + +The library supports two token loading mechanisms: +1. **Token cache file** (preferred): `entrypoint.sh` writes token values to a temporary file (`AWF_TOKEN_CACHE_FILE`) and unsets the env vars before `exec`. The constructor reads the file, populates the cache, and immediately deletes it. +2. **Environment fallback**: If no cache file exists, tokens are read from the environment on library load, cached, and unset (original behavior). This protects against exfiltration via `/proc/self/environ` inspection while allowing legitimate multi-read access patterns that programs like the Copilot CLI require. @@ -48,9 +52,21 @@ LD_PRELOAD=/usr/local/lib/one-shot-token.so ./your-program - If `AWF_ONE_SHOT_TOKENS` is set but contains only whitespace or commas (e.g., `" "` or `",,,"`), the library falls back to the default token list to maintain protection - Use comma-separated token names (whitespace is automatically trimmed) - Maximum of 100 tokens can be protected -- The configuration is read once at library initialization (first `getenv()` call) +- The configuration is read once at library initialization (constructor on library load) - Uses `strtok_r()` internally, which is thread-safe and won't interfere with application code using `strtok()` +### Token Cache File + +The `AWF_TOKEN_CACHE_FILE` environment variable specifies a file containing pre-cached token values. This is set automatically by `entrypoint.sh` to eliminate the `/proc/self/environ` exposure window across `exec` chains. + +**Format:** One `NAME=VALUE` pair per line: +``` +GITHUB_TOKEN=ghp_abc123... +ANTHROPIC_API_KEY=sk-ant-... +``` + +The file is read by the library constructor and immediately deleted. This is an internal mechanism managed by AWF's entrypoint — users should not set this variable manually. + ## How It Works ### The LD_PRELOAD Mechanism diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index d35ca1ff..6e20c1e1 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -238,8 +238,7 @@ static void load_token_cache_file(void) { if (token_cache[idx] != NULL) { token_accessed[idx] = 1; loaded++; - fprintf(stderr, "[one-shot-token] Loaded cached token %s (value: %s)\n", - name, format_token_value(token_cache[idx])); + fprintf(stderr, "[one-shot-token] Loaded cached token %s from file\n", name); } } } @@ -294,8 +293,8 @@ static void one_shot_token_init(void) { token_cache[i] = strdup(value); if (token_cache[i] != NULL) { unsetenv(sensitive_tokens[i]); - fprintf(stderr, "[one-shot-token] Token %s eagerly cached and scrubbed from environ (value: %s)\n", - sensitive_tokens[i], format_token_value(token_cache[i])); + fprintf(stderr, "[one-shot-token] Token %s eagerly cached and scrubbed from environ\n", + sensitive_tokens[i]); } token_accessed[i] = 1; } diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 36532c1d..8768135f 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -906,7 +906,7 @@ export function redactComposeSecrets(workDir: string): void { // GITHUB_TOKEN: ghp_abc123 // Replace the value with ***REDACTED*** const envLinePattern = new RegExp( - `(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(=|:\\s*)(.+)`, + `(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(=|:\\s*)([^\\n]+)`, 'g' ); const newContent = content.replace(envLinePattern, `$1$2***REDACTED***`); From 0a5631a9d3d38da3e9caff3ff4fd85eb9be2131b Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 10 Feb 2026 21:29:49 +0000 Subject: [PATCH 5/7] fix: C compile error and harden docker-compose.yml - Add forward declarations for get_token_index() and format_token_value() in one-shot-token.c. The constructor and load_token_cache_file() call these before their definitions, causing implicit declaration errors with -Wall. - Write docker-compose.yml with mode 0600 so the agent container (running as awfuser) cannot read plaintext secrets via /host mount. Eliminates the TOCTOU window. - Fix TOCTOU in redactComposeSecrets: replace existsSync + readFileSync with single readFileSync in try/catch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent/one-shot-token/one-shot-token.c | 4 ++++ src/docker-manager.ts | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index 6e20c1e1..7316d65f 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -84,6 +84,10 @@ static char *(*real_secure_getenv)(const char *name) = NULL; static pthread_once_t getenv_init_once = PTHREAD_ONCE_INIT; static pthread_once_t secure_getenv_init_once = PTHREAD_ONCE_INIT; +/* Forward declarations */ +static int get_token_index(const char *name); +static const char *format_token_value(const char *value); + /* Initialize the real getenv pointer (called exactly once via pthread_once) */ static void init_real_getenv_once(void) { real_getenv = dlsym(RTLD_NEXT, "getenv"); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 8768135f..9448881c 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -787,8 +787,8 @@ export async function writeConfigs(config: WrapperConfig): Promise { // Write Docker Compose config const dockerCompose = generateDockerCompose(config, networkConfig, sslConfig); const dockerComposePath = path.join(config.workDir, 'docker-compose.yml'); - fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose)); - logger.debug(`Docker Compose config written to: ${dockerComposePath}`); + fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose), { mode: 0o600 }); + logger.debug(`Docker Compose config written to: ${dockerComposePath} (mode 0600)`); } /** @@ -891,13 +891,16 @@ export const SENSITIVE_ENV_NAMES = new Set([ */ export function redactComposeSecrets(workDir: string): void { const composePath = path.join(workDir, 'docker-compose.yml'); - if (!fs.existsSync(composePath)) { - logger.debug('No docker-compose.yml found to redact'); - return; - } try { - let content = fs.readFileSync(composePath, 'utf8'); + // Read directly without existsSync to avoid TOCTOU race condition + let content: string; + try { + content = fs.readFileSync(composePath, 'utf8'); + } catch { + logger.debug('No docker-compose.yml found to redact'); + return; + } let redactedCount = 0; for (const name of SENSITIVE_ENV_NAMES) { @@ -917,7 +920,7 @@ export function redactComposeSecrets(workDir: string): void { } if (redactedCount > 0) { - fs.writeFileSync(composePath, content); + fs.writeFileSync(composePath, content, { mode: 0o600 }); logger.info(`Redacted ${redactedCount} sensitive value(s) from docker-compose.yml`); } } catch (error) { From e767a90506753b17fd20bfd3f5d0559ae48e033e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:42:46 +0000 Subject: [PATCH 6/7] fix: address review feedback for secret exposure mitigations Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/entrypoint.sh | 109 +++++++++--------- containers/agent/one-shot-token/README.md | 5 +- .../agent/one-shot-token/one-shot-token.c | 40 +++---- src/cli-workflow.ts | 12 +- src/docker-manager.ts | 6 +- 5 files changed, 86 insertions(+), 86 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index a512dcf0..01e9211d 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -146,6 +146,54 @@ echo "[entrypoint] Switching to awfuser (UID: $(id -u awfuser), GID: $(id -g awf echo "[entrypoint] Executing command: $@" echo "" +# Default list of sensitive token environment variable names. +# Must stay aligned with DEFAULT_SENSITIVE_TOKENS in one-shot-token.c +# and SENSITIVE_ENV_NAMES in docker-manager.ts. +DEFAULT_SENSITIVE_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,GITHUB_PERSONAL_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" + +# scrub_sensitive_tokens CACHE_FILE_PATH +# +# Writes sensitive token values to a cache file (mode 0600, owned by awfuser), +# then unsets them from the environment. The LD_PRELOAD one-shot-token library +# reads this file on load to populate its in-memory cache so that getenv() +# still returns the values while /proc/self/environ is clean. +# +# The cache file is NOT deleted by the library (it must survive the full exec +# chain: capsh → gosu → user command). Cleanup is handled by the EXIT trap. +scrub_sensitive_tokens() { + local cache_file="$1" + # Use umask to create the file with restricted permissions atomically + (umask 077 && : > "${cache_file}") + # Use AWF_ONE_SHOT_TOKENS if set, otherwise use defaults + local sensitive_tokens + if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then + sensitive_tokens="${AWF_ONE_SHOT_TOKENS}" + else + sensitive_tokens="${DEFAULT_SENSITIVE_TOKENS}" + fi + local token_names + IFS=',' read -ra token_names <<< "${sensitive_tokens}" + local token_name token_value + for token_name in "${token_names[@]}"; do + token_name=$(echo "$token_name" | xargs) # trim whitespace + if [ -n "$token_name" ]; then + token_value=$(printenv "$token_name" 2>/dev/null || true) + if [ -n "$token_value" ]; then + printf '%s=%s\n' "$token_name" "$token_value" >> "${cache_file}" + echo "[entrypoint] Token ${token_name} written to cache file and will be scrubbed from environ" + fi + fi + done + chown "$(id -u awfuser):$(id -g awfuser)" "${cache_file}" 2>/dev/null || true + # Unset sensitive vars so they don't appear in /proc/self/environ of exec'd process + for token_name in "${token_names[@]}"; do + token_name=$(echo "$token_name" | xargs) + if [ -n "$token_name" ]; then + unset "$token_name" 2>/dev/null || true + fi + done +} + # If chroot mode is enabled, run user command INSIDE the chroot /host # This provides transparent host binary access - user command sees host filesystem as / if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then @@ -395,37 +443,10 @@ AWFEOF SCRUB_CMD="" if [ -n "${ONE_SHOT_TOKEN_LIB}" ]; then TOKEN_CACHE_FILE="/tmp/.awf-token-cache-$$" - # Write sensitive token values to cache file (inside chroot perspective) - # The LD_PRELOAD constructor reads this file and deletes it immediately - # Use umask to create the file with restricted permissions atomically - (umask 077 && : > "/host${TOKEN_CACHE_FILE}") - # Use AWF_ONE_SHOT_TOKENS if set, otherwise use defaults - if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then - SENSITIVE_TOKENS="${AWF_ONE_SHOT_TOKENS}" - else - SENSITIVE_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" - fi - IFS=',' read -ra TOKEN_NAMES <<< "${SENSITIVE_TOKENS}" - for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do - TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) # trim whitespace - if [ -n "$TOKEN_NAME" ]; then - TOKEN_VALUE=$(printenv "$TOKEN_NAME" 2>/dev/null || true) - if [ -n "$TOKEN_VALUE" ]; then - echo "${TOKEN_NAME}=${TOKEN_VALUE}" >> "/host${TOKEN_CACHE_FILE}" - echo "[entrypoint] Token ${TOKEN_NAME} written to cache file and will be scrubbed from environ" - fi - fi - done - chown "$(id -u awfuser):$(id -g awfuser)" "/host${TOKEN_CACHE_FILE}" 2>/dev/null || true + # In chroot mode, the file lives under /host but the chroot sees it at TOKEN_CACHE_FILE + scrub_sensitive_tokens "/host${TOKEN_CACHE_FILE}" SCRUB_CMD="export AWF_TOKEN_CACHE_FILE=${TOKEN_CACHE_FILE};" - # Unset sensitive vars so they don't appear in /proc/self/environ of exec'd process - for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do - TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) - if [ -n "$TOKEN_NAME" ]; then - unset "$TOKEN_NAME" 2>/dev/null || true - fi - done - # Also add cache file cleanup to the exit trap + # Also add cache file cleanup to the exit trap (chroot perspective, no /host prefix) CLEANUP_CMD="${CLEANUP_CMD}; rm -f ${TOKEN_CACHE_FILE} 2>/dev/null || true" fi @@ -455,34 +476,8 @@ else # /proc/self/environ exposure. Write values to a cache file so the # LD_PRELOAD library can still serve them via getenv(). TOKEN_CACHE_FILE="/tmp/.awf-token-cache-$$" - # Use umask to create the file with restricted permissions atomically - (umask 077 && : > "${TOKEN_CACHE_FILE}") - # Use AWF_ONE_SHOT_TOKENS if set, otherwise use defaults - if [ -n "${AWF_ONE_SHOT_TOKENS}" ]; then - SENSITIVE_TOKENS="${AWF_ONE_SHOT_TOKENS}" - else - SENSITIVE_TOKENS="COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY" - fi - IFS=',' read -ra TOKEN_NAMES <<< "${SENSITIVE_TOKENS}" - for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do - TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) # trim whitespace - if [ -n "$TOKEN_NAME" ]; then - TOKEN_VALUE=$(printenv "$TOKEN_NAME" 2>/dev/null || true) - if [ -n "$TOKEN_VALUE" ]; then - echo "${TOKEN_NAME}=${TOKEN_VALUE}" >> "${TOKEN_CACHE_FILE}" - echo "[entrypoint] Token ${TOKEN_NAME} written to cache file and will be scrubbed from environ" - fi - fi - done - chown "$(id -u awfuser):$(id -g awfuser)" "${TOKEN_CACHE_FILE}" 2>/dev/null || true + scrub_sensitive_tokens "${TOKEN_CACHE_FILE}" export AWF_TOKEN_CACHE_FILE="${TOKEN_CACHE_FILE}" - # Unset sensitive vars so they don't appear in /proc/self/environ of exec'd process - for TOKEN_NAME in "${TOKEN_NAMES[@]}"; do - TOKEN_NAME=$(echo "$TOKEN_NAME" | xargs) - if [ -n "$TOKEN_NAME" ]; then - unset "$TOKEN_NAME" 2>/dev/null || true - fi - done exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" fi diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index 69eb2d77..a749628b 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -5,8 +5,8 @@ The one-shot token library is an `LD_PRELOAD` shared library that provides **cached access** to sensitive environment variables containing GitHub, OpenAI, Anthropic/Claude, and Codex API tokens. On library load, a constructor eagerly caches all sensitive tokens and removes them from the process environment, ensuring `/proc/self/environ` never exposes secrets to user code. The library supports two token loading mechanisms: -1. **Token cache file** (preferred): `entrypoint.sh` writes token values to a temporary file (`AWF_TOKEN_CACHE_FILE`) and unsets the env vars before `exec`. The constructor reads the file, populates the cache, and immediately deletes it. -2. **Environment fallback**: If no cache file exists, tokens are read from the environment on library load, cached, and unset (original behavior). +1. **Token cache file** (preferred): `entrypoint.sh` writes token values to a temporary file (`AWF_TOKEN_CACHE_FILE`) and unsets the env vars before `exec`. The constructor reads the file and populates the cache. The file is NOT deleted by the library — it must survive the full exec chain (`capsh → gosu → user command`) since each `exec()` resets static data. Cleanup is handled by the EXIT trap in `entrypoint.sh`. +2. **Environment fallback**: If no cache file exists, tokens are read from the environment on library load, cached, and unset. This protects against exfiltration via `/proc/self/environ` inspection while allowing legitimate multi-read access patterns that programs like the Copilot CLI require. @@ -23,6 +23,7 @@ By default, the library protects these token variables: - `GITHUB_API_TOKEN` - `GITHUB_PAT` - `GH_ACCESS_TOKEN` +- `GITHUB_PERSONAL_ACCESS_TOKEN` **OpenAI:** - `OPENAI_API_KEY` diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index 7316d65f..d4644e8c 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -5,13 +5,18 @@ * /proc/self/environ and limits access via getenv(). * * When loaded, the library constructor reads cached token values from - * AWF_TOKEN_CACHE_FILE (written by entrypoint.sh), populates an in-memory - * cache, and immediately deletes the file. The sensitive variables are - * never present in the process environment, so /proc/self/environ is clean. + * AWF_TOKEN_CACHE_FILE (written by entrypoint.sh) and populates an + * in-memory cache. The file is NOT deleted by the library because the + * constructor runs in every process in the exec chain (capsh → gosu → + * user command), and each exec() resets static data. The file is + * cleaned up by the EXIT trap in entrypoint.sh instead. + * + * The sensitive variables are never present in the process environment + * (entrypoint.sh unsets them before exec), so /proc/self/environ is clean. * Subsequent getenv() calls return the cached values from memory. * * Fallback: If no cache file is found, tokens are read from the environment - * on first getenv() call, cached, and unset (original behavior). + * on library load, cached, and unset (original behavior). * * Configuration: * AWF_ONE_SHOT_TOKENS - Comma-separated list of token names to protect @@ -40,6 +45,7 @@ static const char *DEFAULT_SENSITIVE_TOKENS[] = { "GITHUB_API_TOKEN", "GITHUB_PAT", "GH_ACCESS_TOKEN", + "GITHUB_PERSONAL_ACCESS_TOKEN", /* OpenAI tokens */ "OPENAI_API_KEY", "OPENAI_KEY", @@ -200,8 +206,13 @@ static void init_token_list(void) { /** * Load cached token values from AWF_TOKEN_CACHE_FILE. * - * The file format is one NAME=VALUE per line. After reading, the file - * is deleted immediately to minimize the exposure window. + * The file format is one NAME=VALUE per line. The file is NOT deleted here + * because the LD_PRELOAD constructor runs in every process in the exec chain + * (capsh → gosu → user command), and each exec() creates a fresh process + * image with reset static data. Deleting here would cause subsequent processes + * to lose access to the cached tokens. The file is cleaned up by the EXIT + * trap in entrypoint.sh instead. The file is created with mode 0600 and + * owned by awfuser, so it is not readable by other users. * * Must be called with token_mutex held and after init_token_list(). */ @@ -235,7 +246,7 @@ static void load_token_cache_file(void) { const char *name = line; const char *value = eq + 1; - /* Find if this name matches a sensitive token */ + /* Find if this name matches a sensitive token (first-wins for duplicates) */ int idx = get_token_index(name); if (idx >= 0 && !token_accessed[idx]) { token_cache[idx] = strdup(value); @@ -249,16 +260,6 @@ static void load_token_cache_file(void) { fclose(f); - /* Delete the cache file immediately to minimize exposure */ - if (unlink(cache_path) == 0) { - fprintf(stderr, "[one-shot-token] Token cache file deleted: %s\n", cache_path); - } else { - fprintf(stderr, "[one-shot-token] WARNING: Could not delete token cache file: %s\n", cache_path); - } - - /* Also remove AWF_TOKEN_CACHE_FILE from environ */ - unsetenv("AWF_TOKEN_CACHE_FILE"); - if (loaded > 0) { fprintf(stderr, "[one-shot-token] Loaded %d token(s) from cache file\n", loaded); } @@ -268,9 +269,8 @@ static void load_token_cache_file(void) { * Library constructor - runs when the library is loaded (before main()). * * If AWF_TOKEN_CACHE_FILE is set (by entrypoint.sh), loads cached token - * values from the file and deletes it. The sensitive variables are never - * present in /proc/self/environ because entrypoint.sh unsets them before - * exec. + * values from the file. The sensitive variables are never present in + * /proc/self/environ because entrypoint.sh unsets them before exec. * * If no cache file exists, tokens remain in the environment and will be * cached + unset on first getenv() call (original fallback behavior). diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 66019888..530a0bf0 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -54,16 +54,20 @@ export async function runMainWorkflow( // Step 2: Start containers await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull); - // Step 2.5: Redact secrets from docker-compose.yml after containers start - // This prevents exposure via /host/tmp/awf-*/docker-compose.yml + // Step 3: Redact secrets from docker-compose.yml after containers start + // This must happen AFTER docker compose up because Docker reads the file + // to configure container environment variables. Redacting before would + // prevent containers from receiving the tokens they need. + // The race window is mitigated by the file being written with mode 0600 + // (root-only readable), so the agent (running as awfuser) cannot read it. dependencies.redactComposeSecrets(config.workDir); onContainersStarted?.(); - // Step 3: Wait for agent to complete + // Step 4: Wait for agent to complete const result = await dependencies.runAgentCommand(config.workDir, config.allowedDomains, config.proxyLogsDir); - // Step 4: Cleanup (logs will be preserved automatically if they exist) + // Step 5: Cleanup (logs will be preserved automatically if they exist) await performCleanup(); if (result.exitCode === 0) { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 9448881c..bfbf76d2 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -863,9 +863,9 @@ async function checkSquidLogs(workDir: string, proxyLogsDir?: string): Promise<{ * These are redacted from docker-compose.yml after containers start * to prevent exposure via the /host mount. * - * This list must stay aligned with DEFAULT_SENSITIVE_TOKENS in: - * - containers/agent/one-shot-token/one-shot-token.c - * - containers/agent/entrypoint.sh (SENSITIVE_TOKENS variable) + * This list must stay aligned with: + * - DEFAULT_SENSITIVE_TOKENS in containers/agent/one-shot-token/one-shot-token.c + * - DEFAULT_SENSITIVE_TOKENS in containers/agent/entrypoint.sh */ export const SENSITIVE_ENV_NAMES = new Set([ 'COPILOT_GITHUB_TOKEN', From 24c55a2d2311c42524ce84256f18329d324b9a76 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 10 Feb 2026 23:54:28 +0000 Subject: [PATCH 7/7] fix: quote redacted values in docker-compose.yml to preserve valid YAML The `***REDACTED***` replacement value contains `*` which is a YAML alias character. When unquoted, docker compose fails to parse the file with "did not find expected alphabetic or numeric character". Wrapping in double quotes produces valid YAML that docker compose can still read during cleanup (docker compose down -v). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/docker-manager.test.ts | 10 +++++----- src/docker-manager.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index ac0d2e79..133922b8 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1948,8 +1948,8 @@ describe('docker-manager', () => { redactComposeSecrets(tmpDir); const result = fs.readFileSync(composePath, 'utf8'); - expect(result).toContain('GITHUB_TOKEN=***REDACTED***'); - expect(result).toContain('ANTHROPIC_API_KEY=***REDACTED***'); + expect(result).toContain('GITHUB_TOKEN="***REDACTED***"'); + expect(result).toContain('ANTHROPIC_API_KEY="***REDACTED***"'); expect(result).not.toContain('ghp_abc123secret456'); expect(result).not.toContain('sk-ant-secret789'); // Non-sensitive vars should remain @@ -1971,8 +1971,8 @@ describe('docker-manager', () => { redactComposeSecrets(tmpDir); const result = fs.readFileSync(composePath, 'utf8'); - expect(result).toContain('GITHUB_TOKEN: ***REDACTED***'); - expect(result).toContain('GH_TOKEN: ***REDACTED***'); + expect(result).toContain('GITHUB_TOKEN: "***REDACTED***"'); + expect(result).toContain('GH_TOKEN: "***REDACTED***"'); expect(result).not.toContain('ghp_abc123secret456'); expect(result).not.toContain('gho_another_secret'); }); @@ -2011,7 +2011,7 @@ describe('docker-manager', () => { const result = fs.readFileSync(composePath, 'utf8'); for (const name of SENSITIVE_ENV_NAMES) { - expect(result).toContain(`${name}=***REDACTED***`); + expect(result).toContain(`${name}="***REDACTED***"`); expect(result).not.toContain(`secret_value_for_${name}`); } }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index b08c0277..375da100 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -944,7 +944,7 @@ export function redactComposeSecrets(workDir: string): void { `(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(=|:\\s*)([^\\n]+)`, 'g' ); - const newContent = content.replace(envLinePattern, `$1$2***REDACTED***`); + const newContent = content.replace(envLinePattern, `$1$2"***REDACTED***"`); if (newContent !== content) { redactedCount++; content = newContent;