From e59410b847e6b15901c00348a8de8ea1278cd096 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 9 Feb 2026 20:55:13 -0800 Subject: [PATCH 01/11] feat: add one-shot token LD_PRELOAD library for single-use token access Adds an LD_PRELOAD library that intercepts getenv() calls for sensitive GitHub token environment variables. On first access, returns the real value and immediately unsets the variable, preventing subsequent reads by malicious code. Protected tokens: COPILOT_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN, GITHUB_API_TOKEN, GITHUB_PAT, GH_ACCESS_TOKEN - Add one-shot-token.c with getenv interception logic - Build library in Dockerfile during image build - Enable LD_PRELOAD in entrypoint for both container and chroot modes - Add documentation explaining the mechanism and security properties --- containers/agent/Dockerfile | 11 + containers/agent/entrypoint.sh | 27 +++ containers/agent/one-shot-token/README.md | 208 ++++++++++++++++++ containers/agent/one-shot-token/build.sh | 34 +++ .../agent/one-shot-token/one-shot-token.c | 132 +++++++++++ 5 files changed, 412 insertions(+) create mode 100644 containers/agent/one-shot-token/README.md create mode 100644 containers/agent/one-shot-token/build.sh create mode 100644 containers/agent/one-shot-token/one-shot-token.c diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index 1a9c16dd1..75e49c84e 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -66,6 +66,17 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY pid-logger.sh /usr/local/bin/pid-logger.sh RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh +# Build one-shot-token LD_PRELOAD library for single-use token access +# This prevents tokens from being read multiple times (e.g., by malicious code) +COPY one-shot-token/one-shot-token.c /tmp/one-shot-token.c +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc libc6-dev && \ + gcc -shared -fPIC -O2 -Wall -o /usr/local/lib/one-shot-token.so /tmp/one-shot-token.c -ldl -lpthread && \ + rm /tmp/one-shot-token.c && \ + apt-get remove -y gcc libc6-dev && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + # Install Docker stub script that shows helpful error message # Docker-in-Docker support was removed in v0.9.1 COPY docker-stub.sh /usr/bin/docker diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 4c3c46794..c7345e8e1 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -167,6 +167,19 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then exit 1 fi + # Copy one-shot-token library to host filesystem for LD_PRELOAD in chroot + # This prevents tokens from being read multiple times by malicious code + ONE_SHOT_TOKEN_LIB="" + if [ -f /usr/local/lib/one-shot-token.so ]; then + mkdir -p /host/tmp/awf-lib + if cp /usr/local/lib/one-shot-token.so /host/tmp/awf-lib/one-shot-token.so 2>/dev/null; then + ONE_SHOT_TOKEN_LIB="/tmp/awf-lib/one-shot-token.so" + echo "[entrypoint] One-shot token library copied to chroot at ${ONE_SHOT_TOKEN_LIB}" + else + echo "[entrypoint][WARN] Could not copy one-shot-token library to chroot" + fi + fi + # Verify capsh is available on the host (required for privilege drop) if ! chroot /host which capsh >/dev/null 2>&1; then echo "[entrypoint][ERROR] capsh not found on host system" @@ -355,10 +368,21 @@ AWFEOF CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/^[0-9.]\\+[[:space:]]\\+host\\.docker\\.internal\$/d' /etc/hosts 2>/dev/null || true" echo "[entrypoint] host.docker.internal will be removed from /etc/hosts on exit" fi + # Clean up the one-shot-token library if it was copied + if [ -n "${ONE_SHOT_TOKEN_LIB}" ]; then + CLEANUP_CMD="${CLEANUP_CMD}; rm -rf /tmp/awf-lib 2>/dev/null || true" + fi + + # Build LD_PRELOAD command for one-shot token protection + LD_PRELOAD_CMD="" + if [ -n "${ONE_SHOT_TOKEN_LIB}" ]; then + LD_PRELOAD_CMD="export LD_PRELOAD=${ONE_SHOT_TOKEN_LIB};" + fi exec chroot /host /bin/bash -c " cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / trap '${CLEANUP_CMD}' EXIT + ${LD_PRELOAD_CMD} exec capsh --drop=${CAPS_TO_DROP} --user=${HOST_USER} -- -c 'exec ${SCRIPT_FILE}' " else @@ -371,5 +395,8 @@ else # 1. capsh drops capabilities from the bounding set (cannot be regained) # 2. gosu switches to awfuser (drops root privileges) # 3. exec replaces the current process with the user command + # + # Enable one-shot token protection to prevent tokens from being read multiple times + export LD_PRELOAD=/usr/local/lib/one-shot-token.so 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 new file mode 100644 index 000000000..372054d7e --- /dev/null +++ b/containers/agent/one-shot-token/README.md @@ -0,0 +1,208 @@ +# One-Shot Token Library + +## Overview + +The one-shot token library is an `LD_PRELOAD` shared library that provides **single-use access** to sensitive GitHub token environment variables. When a process reads a protected token via `getenv()`, the library returns the value once and immediately unsets the environment variable, preventing subsequent reads. + +This protects against malicious code that might attempt to exfiltrate tokens after the legitimate application has already consumed them. + +## Protected Environment Variables + +The library intercepts access to these token variables: + +**GitHub:** +- `COPILOT_GITHUB_TOKEN` +- `GITHUB_TOKEN` +- `GH_TOKEN` +- `GITHUB_API_TOKEN` +- `GITHUB_PAT` +- `GH_ACCESS_TOKEN` + +**OpenAI:** +- `OPENAI_API_KEY` +- `OPENAI_KEY` + +**Anthropic/Claude:** +- `ANTHROPIC_API_KEY` +- `CLAUDE_API_KEY` + +**Codex:** +- `CODEX_API_KEY` + +## How It Works + +### The LD_PRELOAD Mechanism + +Linux's dynamic linker (`ld.so`) supports an environment variable called `LD_PRELOAD` that specifies shared libraries to load **before** all others. When a library is preloaded: + +1. Its symbols take precedence over symbols in subsequently loaded libraries +2. This allows "interposing" or replacing standard library functions +3. The original function remains accessible via `dlsym(RTLD_NEXT, ...)` + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Process Memory │ +│ │ +│ ┌──────────────────────┐ │ +│ │ one-shot-token.so │ ← Loaded first via LD_PRELOAD │ +│ │ getenv() ──────────┼──┐ │ +│ └──────────────────────┘ │ │ +│ │ dlsym(RTLD_NEXT, "getenv") │ +│ ┌──────────────────────┐ │ │ +│ │ libc.so │ │ │ +│ │ getenv() ←─────────┼──┘ │ +│ └──────────────────────┘ │ +│ │ +│ Application calls getenv("GITHUB_TOKEN"): │ +│ 1. Resolves to one-shot-token.so's getenv() │ +│ 2. We check if it's a sensitive token │ +│ 3. If yes: call real getenv(), copy value, unsetenv(), return │ +│ 4. If no: pass through to real getenv() │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Token Access Flow + +``` +First getenv("GITHUB_TOKEN") call: +┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ +│ Application │────→│ one-shot-token.so │────→│ Real getenv │ +│ │ │ │ │ │ +│ │←────│ Returns: "ghp_..." │←────│ "ghp_..." │ +└─────────────┘ │ │ └─────────────┘ + │ Then: unsetenv() │ + │ Mark as accessed │ + └──────────────────────┘ + +Second getenv("GITHUB_TOKEN") call: +┌─────────────┐ ┌──────────────────┐ +│ Application │────→│ one-shot-token.so │ +│ │ │ │ +│ │←────│ Returns: NULL │ (token already accessed) +└─────────────┘ └──────────────────────┘ +``` + +### Thread Safety + +The library uses a pthread mutex to ensure thread-safe access to the token state. Multiple threads calling `getenv()` simultaneously will be serialized for sensitive tokens, ensuring only one thread receives the actual value. + +## Why This Works + +### 1. Symbol Interposition + +When `LD_PRELOAD=/usr/local/lib/one-shot-token.so` is set, the dynamic linker loads our library first. Any subsequent call to `getenv()` from the application or its libraries resolves to **our** implementation, not libc's. + +### 2. Access to Original Function + +We use `dlsym(RTLD_NEXT, "getenv")` to get a pointer to the **next** `getenv` in the symbol search order (libc's implementation). This allows us to: +- Call the real `getenv()` to retrieve the actual value +- Return that value to the caller +- Then call `unsetenv()` to remove it from the environment + +### 3. State Tracking + +We maintain an array of flags (`token_accessed[]`) to track which tokens have been read. Once a token is marked as accessed, subsequent calls return `NULL` without consulting the environment. + +### 4. Memory Management + +When we retrieve a token value, we `strdup()` it before calling `unsetenv()`. This is necessary because: +- `getenv()` returns a pointer to memory owned by the environment +- `unsetenv()` invalidates that pointer +- The caller expects a valid string, so we must copy it first + +Note: This memory is intentionally never freed—it must remain valid for the lifetime of the caller's use. + +## Integration with AWF + +### Container Mode (non-chroot) + +The library is built into the agent container image and loaded via: + +```bash +export LD_PRELOAD=/usr/local/lib/one-shot-token.so +exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $COMMAND" +``` + +### Chroot Mode + +In chroot mode, the library must be accessible from within the chroot (host filesystem). The entrypoint: + +1. Copies the library from container to `/host/tmp/awf-lib/one-shot-token.so` +2. Sets `LD_PRELOAD=/tmp/awf-lib/one-shot-token.so` inside the chroot +3. Cleans up the library on exit + +## Building + +### In Docker (automatic) + +The Dockerfile compiles the library during image build: + +```dockerfile +RUN gcc -shared -fPIC -O2 -Wall \ + -o /usr/local/lib/one-shot-token.so \ + /tmp/one-shot-token.c \ + -ldl -lpthread +``` + +### Locally (for testing) + +```bash +./build.sh +``` + +This produces `one-shot-token.so` in the current directory. + +## Testing + +```bash +# Build the library +./build.sh + +# Test with a simple program +export GITHUB_TOKEN="test-token-12345" +LD_PRELOAD=./one-shot-token.so bash -c ' + echo "First read: $(printenv GITHUB_TOKEN)" + echo "Second read: $(printenv GITHUB_TOKEN)" +' +``` + +Expected output: +``` +[one-shot-token] Token GITHUB_TOKEN accessed and cleared +First read: test-token-12345 +Second read: +``` + +## Security Considerations + +### What This Protects Against + +- **Token reuse by injected code**: If malicious code runs after the legitimate application has read its token, it cannot retrieve the token again +- **Token leakage via environment inspection**: Tools like `printenv` or reading `/proc/self/environ` will not show the token after first access + +### What This Does NOT Protect Against + +- **Memory inspection**: The token exists in process memory (as the returned string) +- **Interception before first read**: If malicious code runs before the legitimate code reads the token, it gets the value +- **Static linking**: Programs statically linked with libc bypass LD_PRELOAD +- **Direct syscalls**: Code that reads `/proc/self/environ` directly (without getenv) bypasses this protection + +### Defense in Depth + +This library is one layer in AWF's security model: +1. **Network isolation**: iptables rules redirect traffic through Squid proxy +2. **Domain allowlisting**: Squid blocks requests to non-allowed domains +3. **Capability dropping**: CAP_NET_ADMIN is dropped to prevent iptables modification +4. **One-shot tokens**: This library prevents token reuse + +## Limitations + +- **x86_64 Linux only**: The library is compiled for x86_64 Ubuntu +- **glibc programs only**: Programs using musl libc or statically linked programs are not affected +- **Single process**: Child processes inherit the LD_PRELOAD but have their own token state (each can read once) + +## Files + +- `one-shot-token.c` - Library source code +- `build.sh` - Local build script +- `README.md` - This documentation diff --git a/containers/agent/one-shot-token/build.sh b/containers/agent/one-shot-token/build.sh new file mode 100644 index 000000000..79227f8fe --- /dev/null +++ b/containers/agent/one-shot-token/build.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Build the one-shot-token LD_PRELOAD library +# This script compiles the shared library for x86_64 Ubuntu + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOURCE_FILE="${SCRIPT_DIR}/one-shot-token.c" +OUTPUT_FILE="${SCRIPT_DIR}/one-shot-token.so" + +echo "[build] Compiling one-shot-token.so..." + +# Compile as a shared library with position-independent code +# -shared: create a shared library +# -fPIC: position-independent code (required for shared libs) +# -ldl: link with libdl for dlsym +# -lpthread: link with pthread for mutex +# -O2: optimize for performance +# -Wall -Wextra: enable warnings +gcc -shared -fPIC \ + -O2 -Wall -Wextra \ + -o "${OUTPUT_FILE}" \ + "${SOURCE_FILE}" \ + -ldl -lpthread + +echo "[build] Successfully built: ${OUTPUT_FILE}" + +# Verify it's a valid shared library +if file "${OUTPUT_FILE}" | grep -q "shared object"; then + echo "[build] Verified: valid shared object" +else + echo "[build] ERROR: Output is not a valid shared object" + exit 1 +fi diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c new file mode 100644 index 000000000..73b224fc8 --- /dev/null +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -0,0 +1,132 @@ +/** + * One-Shot Token LD_PRELOAD Library + * + * Intercepts getenv() calls for sensitive token environment variables. + * On first access, returns the real value and immediately unsets the variable. + * Subsequent calls return NULL, preventing token reuse by malicious code. + * + * Compile: gcc -shared -fPIC -o one-shot-token.so one-shot-token.c -ldl + * Usage: LD_PRELOAD=/path/to/one-shot-token.so ./your-program + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +/* Sensitive token environment variable names */ +static const char *SENSITIVE_TOKENS[] = { + /* GitHub tokens */ + "COPILOT_GITHUB_TOKEN", + "GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_API_TOKEN", + "GITHUB_PAT", + "GH_ACCESS_TOKEN", + /* OpenAI tokens */ + "OPENAI_API_KEY", + "OPENAI_KEY", + /* Anthropic/Claude tokens */ + "ANTHROPIC_API_KEY", + "CLAUDE_API_KEY", + /* Codex tokens */ + "CODEX_API_KEY", + NULL +}; + +/* Track which tokens have been accessed (one flag per token) */ +static int token_accessed[sizeof(SENSITIVE_TOKENS) / sizeof(SENSITIVE_TOKENS[0])] = {0}; + +/* Mutex for thread safety */ +static pthread_mutex_t token_mutex = PTHREAD_MUTEX_INITIALIZER; + +/* Pointer to the real getenv function */ +static char *(*real_getenv)(const char *name) = NULL; + +/* Initialize the real getenv pointer */ +static void init_real_getenv(void) { + if (real_getenv == NULL) { + real_getenv = dlsym(RTLD_NEXT, "getenv"); + if (real_getenv == NULL) { + fprintf(stderr, "[one-shot-token] ERROR: Could not find real getenv: %s\n", dlerror()); + /* Fall back to a no-op to prevent crash */ + abort(); + } + } +} + +/* Check if a variable name is a sensitive token */ +static int get_token_index(const char *name) { + if (name == NULL) return -1; + + for (int i = 0; SENSITIVE_TOKENS[i] != NULL; i++) { + if (strcmp(name, SENSITIVE_TOKENS[i]) == 0) { + return i; + } + } + return -1; +} + +/** + * Intercepted getenv function + * + * For sensitive tokens: + * - First call: returns the real value, then unsets the variable + * - Subsequent calls: returns NULL + * + * For all other variables: passes through to real getenv + */ +char *getenv(const char *name) { + init_real_getenv(); + + int token_idx = get_token_index(name); + + /* Not a sensitive token - pass through */ + if (token_idx < 0) { + return real_getenv(name); + } + + /* Sensitive token - handle one-shot access */ + pthread_mutex_lock(&token_mutex); + + char *result = NULL; + + if (!token_accessed[token_idx]) { + /* First access - get the real value */ + result = real_getenv(name); + + if (result != NULL) { + /* Make a copy since unsetenv will invalidate the pointer */ + /* Note: This memory is intentionally never freed - it must persist + * for the lifetime of the caller's use of the returned pointer */ + result = strdup(result); + + /* Unset the variable so it can't be accessed again */ + unsetenv(name); + + fprintf(stderr, "[one-shot-token] Token %s accessed and cleared\n", name); + } + + /* Mark as accessed even if NULL (prevents repeated log messages) */ + token_accessed[token_idx] = 1; + } else { + /* Already accessed - return NULL */ + result = NULL; + } + + pthread_mutex_unlock(&token_mutex); + + return result; +} + +/** + * Also intercept secure_getenv for completeness + * (some security-conscious code uses this instead of getenv) + */ +char *secure_getenv(const char *name) { + /* secure_getenv returns NULL if the program is running with elevated privileges. + * We delegate to our intercepted getenv which handles the one-shot logic. */ + return getenv(name); +} From 6f9353464a80c051445279ecce15bf1f8ed429f5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:13:11 -0800 Subject: [PATCH 02/11] fix: improve one-shot token library copy robustness in chroot mode (#606) * Initial plan * fix: improve one-shot token library copy with better error handling Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- containers/agent/entrypoint.sh | 20 +++++++++++++++----- package-lock.json | 9 +++++++++ src/docker-manager.ts | 5 +++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index c7345e8e1..775f832d7 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -169,14 +169,24 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then # Copy one-shot-token library to host filesystem for LD_PRELOAD in chroot # This prevents tokens from being read multiple times by malicious code + # Note: /tmp is always writable in chroot mode (mounted from host /tmp as rw) ONE_SHOT_TOKEN_LIB="" if [ -f /usr/local/lib/one-shot-token.so ]; then - mkdir -p /host/tmp/awf-lib - if cp /usr/local/lib/one-shot-token.so /host/tmp/awf-lib/one-shot-token.so 2>/dev/null; then - ONE_SHOT_TOKEN_LIB="/tmp/awf-lib/one-shot-token.so" - echo "[entrypoint] One-shot token library copied to chroot at ${ONE_SHOT_TOKEN_LIB}" + # Create the library directory in /tmp (always writable) + if mkdir -p /host/tmp/awf-lib 2>/dev/null; then + # Copy the library and verify it exists after copying + if cp /usr/local/lib/one-shot-token.so /host/tmp/awf-lib/one-shot-token.so 2>/dev/null && \ + [ -f /host/tmp/awf-lib/one-shot-token.so ]; then + ONE_SHOT_TOKEN_LIB="/tmp/awf-lib/one-shot-token.so" + echo "[entrypoint] One-shot token library copied to chroot at ${ONE_SHOT_TOKEN_LIB}" + else + echo "[entrypoint][WARN] Could not copy one-shot-token library to /tmp/awf-lib" + echo "[entrypoint][WARN] Token protection will be disabled (tokens may be readable multiple times)" + fi else - echo "[entrypoint][WARN] Could not copy one-shot-token library to chroot" + echo "[entrypoint][ERROR] Could not create /tmp/awf-lib directory" + echo "[entrypoint][ERROR] This should not happen - /tmp is mounted read-write in chroot mode" + echo "[entrypoint][WARN] Token protection will be disabled (tokens may be readable multiple times)" fi fi diff --git a/package-lock.json b/package-lock.json index 59f1e780b..b062dac44 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", @@ -3090,6 +3091,7 @@ "integrity": "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3167,6 +3169,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3336,6 +3339,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3699,6 +3703,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3980,6 +3985,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4261,6 +4267,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", @@ -5316,6 +5323,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7512,6 +7520,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/docker-manager.ts b/src/docker-manager.ts index e30923c53..da068d3d1 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -478,8 +478,9 @@ export function generateDockerCompose( const userHome = getRealUserHome(); agentVolumes.push(`${userHome}:/host${userHome}:rw`); - // /tmp is needed for chroot mode to write temporary command scripts - // The entrypoint.sh writes to /host/tmp/awf-cmd-$$.sh + // /tmp is needed for chroot mode to write: + // - Temporary command scripts: /host/tmp/awf-cmd-$$.sh + // - One-shot token LD_PRELOAD library: /host/tmp/awf-lib/one-shot-token.so agentVolumes.push('/tmp:/host/tmp:rw'); // Minimal /etc - only what's needed for runtime From c76b06f7c4eaed4f09ebd1e07332459d964ef7e2 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 9 Feb 2026 22:22:38 -0800 Subject: [PATCH 03/11] Update containers/agent/one-shot-token/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- containers/agent/one-shot-token/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index 372054d7e..297bf319c 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -2,7 +2,7 @@ ## Overview -The one-shot token library is an `LD_PRELOAD` shared library that provides **single-use access** to sensitive GitHub token environment variables. When a process reads a protected token via `getenv()`, the library returns the value once and immediately unsets the environment variable, preventing subsequent reads. +The one-shot token library is an `LD_PRELOAD` shared library that provides **single-use 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 returns the value once and immediately unsets the environment variable, preventing subsequent reads. This protects against malicious code that might attempt to exfiltrate tokens after the legitimate application has already consumed them. From 7b5ccc54dbfcccad8171d80b6990d0cc46450137 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 9 Feb 2026 22:22:58 -0800 Subject: [PATCH 04/11] Update containers/agent/one-shot-token/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- containers/agent/one-shot-token/README.md | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index 297bf319c..dc2c0b2a8 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -158,12 +158,28 @@ This produces `one-shot-token.so` in the current directory. # Build the library ./build.sh -# Test with a simple program +# Create a simple C program that calls getenv twice +cat > test_getenv.c << 'EOF' +#include +#include + +int main(void) { + const char *token1 = getenv("GITHUB_TOKEN"); + printf("First read: %s\n", token1 ? token1 : ""); + + const char *token2 = getenv("GITHUB_TOKEN"); + printf("Second read: %s\n", token2 ? token2 : ""); + + return 0; +} +EOF + +# Compile the test program +gcc -o test_getenv test_getenv.c + +# Test with the one-shot token library preloaded export GITHUB_TOKEN="test-token-12345" -LD_PRELOAD=./one-shot-token.so bash -c ' - echo "First read: $(printenv GITHUB_TOKEN)" - echo "Second read: $(printenv GITHUB_TOKEN)" -' +LD_PRELOAD=./one-shot-token.so ./test_getenv ``` Expected output: From 62645c88b21dd8c6cc5d01918b9fac30996adf52 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:28:40 -0800 Subject: [PATCH 05/11] fix: use pthread_once for thread-safe getenv initialization (#609) * Initial plan * fix: use pthread_once for thread-safe getenv initialization Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * chore: complete thread safety fix Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- _codeql_detected_source_root | 1 + .../agent/one-shot-token/one-shot-token.c | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) create mode 120000 _codeql_detected_source_root diff --git a/_codeql_detected_source_root b/_codeql_detected_source_root new file mode 120000 index 000000000..945c9b46d --- /dev/null +++ b/_codeql_detected_source_root @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index 73b224fc8..6cede43bf 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -45,18 +45,24 @@ static pthread_mutex_t token_mutex = PTHREAD_MUTEX_INITIALIZER; /* Pointer to the real getenv function */ static char *(*real_getenv)(const char *name) = NULL; -/* Initialize the real getenv pointer */ -static void init_real_getenv(void) { +/* pthread_once control for thread-safe initialization */ +static pthread_once_t getenv_init_once = PTHREAD_ONCE_INIT; + +/* Initialize the real getenv pointer (called exactly once via pthread_once) */ +static void init_real_getenv_once(void) { + real_getenv = dlsym(RTLD_NEXT, "getenv"); if (real_getenv == NULL) { - real_getenv = dlsym(RTLD_NEXT, "getenv"); - if (real_getenv == NULL) { - fprintf(stderr, "[one-shot-token] ERROR: Could not find real getenv: %s\n", dlerror()); - /* Fall back to a no-op to prevent crash */ - abort(); - } + fprintf(stderr, "[one-shot-token] FATAL: Could not find real getenv: %s\n", dlerror()); + /* Cannot recover - abort to prevent undefined behavior */ + abort(); } } +/* Ensure real_getenv is initialized (thread-safe) */ +static void init_real_getenv(void) { + pthread_once(&getenv_init_once, init_real_getenv_once); +} + /* Check if a variable name is a sensitive token */ static int get_token_index(const char *name) { if (name == NULL) return -1; From 1837d5b76980026c3fdbae793739b10c8d98053e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:30:20 -0800 Subject: [PATCH 06/11] test: add integration tests for one-shot token LD_PRELOAD library (#608) * Initial plan * test: add integration tests for one-shot token LD_PRELOAD library Add comprehensive integration tests that verify the one-shot token protection mechanism works correctly in both container and chroot modes. Tests verify: - Protected tokens (GITHUB_TOKEN, COPILOT_GITHUB_TOKEN, OPENAI_API_KEY) can be read once - Subsequent reads return empty/null (token has been cleared) - Non-sensitive environment variables are not affected - Multiple tokens are handled independently - Behavior works with both shell (printenv) and programmatic (Python getenv) access - Edge cases (empty values, nonexistent tokens, special characters) are handled Tests address feedback from PR #604 review requesting integration tests for the one-shot token feature to prevent regressions. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- tests/integration/one-shot-tokens.test.ts | 462 ++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 tests/integration/one-shot-tokens.test.ts diff --git a/tests/integration/one-shot-tokens.test.ts b/tests/integration/one-shot-tokens.test.ts new file mode 100644 index 000000000..b5af984bc --- /dev/null +++ b/tests/integration/one-shot-tokens.test.ts @@ -0,0 +1,462 @@ +/** + * One-Shot Token Tests + * + * These tests verify the LD_PRELOAD one-shot token library that prevents + * sensitive environment variables from being read multiple times. + * + * The library intercepts getenv() calls for tokens like GITHUB_TOKEN and + * returns the value once, then unsets the variable to prevent malicious + * code from exfiltrating tokens after legitimate use. + * + * Tests verify: + * - First read succeeds and returns the token value + * - Second read returns empty/null (token has been cleared) + * - Behavior works in both container mode and chroot mode + * + * IMPORTANT: These tests require buildLocal: true because the one-shot-token + * library is compiled during the Docker image build. Pre-built images from GHCR + * may not include this feature if they were built before PR #604 was merged. + */ + +/// + +import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import { createRunner, AwfRunner } from '../fixtures/awf-runner'; +import { cleanup } from '../fixtures/cleanup'; + +describe('One-Shot Token Protection', () => { + let runner: AwfRunner; + + beforeAll(async () => { + await cleanup(false); + runner = createRunner(); + }); + + afterAll(async () => { + await cleanup(false); + }); + + describe('Container Mode', () => { + test('should allow GITHUB_TOKEN to be read once, then clear it', async () => { + // Create a test script that reads the token twice + const testScript = ` + FIRST_READ=$(printenv GITHUB_TOKEN) + SECOND_READ=$(printenv GITHUB_TOKEN) + echo "First read: [$FIRST_READ]" + echo "Second read: [$SECOND_READ]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, // Build container locally to include one-shot-token.so + env: { + GITHUB_TOKEN: 'ghp_test_token_12345', + }, + } + ); + + expect(result).toSucceed(); + // First read should have the token + expect(result.stdout).toContain('First read: [ghp_test_token_12345]'); + // Second read should be empty (token has been cleared) + expect(result.stdout).toContain('Second read: []'); + // Verify the one-shot-token library logged the token access + expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cleared'); + }, 120000); + + test('should allow COPILOT_GITHUB_TOKEN to be read once, then clear it', async () => { + const testScript = ` + FIRST_READ=$(printenv COPILOT_GITHUB_TOKEN) + SECOND_READ=$(printenv COPILOT_GITHUB_TOKEN) + echo "First read: [$FIRST_READ]" + echo "Second read: [$SECOND_READ]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + env: { + COPILOT_GITHUB_TOKEN: 'copilot_test_token_67890', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First read: [copilot_test_token_67890]'); + expect(result.stdout).toContain('Second read: []'); + expect(result.stderr).toContain('[one-shot-token] Token COPILOT_GITHUB_TOKEN accessed and cleared'); + }, 120000); + + test('should allow OPENAI_API_KEY to be read once, then clear it', async () => { + const testScript = ` + FIRST_READ=$(printenv OPENAI_API_KEY) + SECOND_READ=$(printenv OPENAI_API_KEY) + echo "First read: [$FIRST_READ]" + echo "Second read: [$SECOND_READ]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + env: { + OPENAI_API_KEY: 'sk-test-openai-key', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First read: [sk-test-openai-key]'); + expect(result.stdout).toContain('Second read: []'); + expect(result.stderr).toContain('[one-shot-token] Token OPENAI_API_KEY accessed and cleared'); + }, 120000); + + test('should handle multiple different tokens independently', async () => { + const testScript = ` + # Read GITHUB_TOKEN twice + GITHUB_FIRST=$(printenv GITHUB_TOKEN) + GITHUB_SECOND=$(printenv GITHUB_TOKEN) + + # Read OPENAI_API_KEY twice + OPENAI_FIRST=$(printenv OPENAI_API_KEY) + OPENAI_SECOND=$(printenv OPENAI_API_KEY) + + echo "GitHub first: [$GITHUB_FIRST]" + echo "GitHub second: [$GITHUB_SECOND]" + echo "OpenAI first: [$OPENAI_FIRST]" + echo "OpenAI second: [$OPENAI_SECOND]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + env: { + GITHUB_TOKEN: 'ghp_multi_token_1', + OPENAI_API_KEY: 'sk-multi-key-2', + }, + } + ); + + expect(result).toSucceed(); + // Each token should be readable once + expect(result.stdout).toContain('GitHub first: [ghp_multi_token_1]'); + expect(result.stdout).toContain('GitHub second: []'); + expect(result.stdout).toContain('OpenAI first: [sk-multi-key-2]'); + expect(result.stdout).toContain('OpenAI second: []'); + }, 120000); + + test('should not interfere with non-sensitive environment variables', async () => { + const testScript = ` + # Non-sensitive variables should be readable multiple times + FIRST=$(printenv NORMAL_VAR) + SECOND=$(printenv NORMAL_VAR) + THIRD=$(printenv NORMAL_VAR) + echo "First: [$FIRST]" + echo "Second: [$SECOND]" + echo "Third: [$THIRD]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + env: { + NORMAL_VAR: 'not_a_token', + }, + } + ); + + expect(result).toSucceed(); + // Non-sensitive variables should be readable multiple times + expect(result.stdout).toContain('First: [not_a_token]'); + expect(result.stdout).toContain('Second: [not_a_token]'); + expect(result.stdout).toContain('Third: [not_a_token]'); + // No one-shot-token log message for non-sensitive vars + expect(result.stderr).not.toContain('[one-shot-token] Token NORMAL_VAR'); + }, 120000); + + test('should work with programmatic getenv() calls', async () => { + // Use Python to call getenv() directly (not through shell) + // This tests that the LD_PRELOAD library properly intercepts C library calls + const pythonScript = ` +import os +# First call to os.getenv calls C's getenv() +first = os.getenv('GITHUB_TOKEN', '') +# Second call should return None/empty because token was cleared +second = os.getenv('GITHUB_TOKEN', '') +print(f"First: [{first}]") +print(f"Second: [{second}]") + `.trim(); + + const result = await runner.runWithSudo( + `python3 -c '${pythonScript}'`, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + env: { + GITHUB_TOKEN: 'ghp_python_test_token', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First: [ghp_python_test_token]'); + expect(result.stdout).toContain('Second: []'); + expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cleared'); + }, 120000); + }); + + describe('Chroot Mode', () => { + test('should allow GITHUB_TOKEN to be read once in chroot mode', async () => { + const testScript = ` + FIRST_READ=$(printenv GITHUB_TOKEN) + SECOND_READ=$(printenv GITHUB_TOKEN) + echo "First read: [$FIRST_READ]" + echo "Second read: [$SECOND_READ]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + enableChroot: true, + env: { + GITHUB_TOKEN: 'ghp_chroot_token_12345', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First read: [ghp_chroot_token_12345]'); + expect(result.stdout).toContain('Second read: []'); + // Verify the library was copied to the chroot + expect(result.stderr).toContain('One-shot token library copied to chroot'); + // Verify the one-shot-token library logged the token access + expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cleared'); + }, 120000); + + test('should allow COPILOT_GITHUB_TOKEN to be read once in chroot mode', async () => { + const testScript = ` + FIRST_READ=$(printenv COPILOT_GITHUB_TOKEN) + SECOND_READ=$(printenv COPILOT_GITHUB_TOKEN) + echo "First read: [$FIRST_READ]" + echo "Second read: [$SECOND_READ]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + enableChroot: true, + env: { + COPILOT_GITHUB_TOKEN: 'copilot_chroot_token_67890', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First read: [copilot_chroot_token_67890]'); + expect(result.stdout).toContain('Second read: []'); + expect(result.stderr).toContain('[one-shot-token] Token COPILOT_GITHUB_TOKEN accessed and cleared'); + }, 120000); + + test('should work with programmatic getenv() calls in chroot mode', async () => { + const pythonScript = ` +import os +first = os.getenv('GITHUB_TOKEN', '') +second = os.getenv('GITHUB_TOKEN', '') +print(f"First: [{first}]") +print(f"Second: [{second}]") + `.trim(); + + const result = await runner.runWithSudo( + `python3 -c '${pythonScript}'`, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + enableChroot: true, + env: { + GITHUB_TOKEN: 'ghp_chroot_python_token', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First: [ghp_chroot_python_token]'); + expect(result.stdout).toContain('Second: []'); + expect(result.stderr).toContain('[one-shot-token] Token GITHUB_TOKEN accessed and cleared'); + }, 120000); + + test('should not interfere with non-sensitive variables in chroot mode', async () => { + const testScript = ` + FIRST=$(printenv NORMAL_VAR) + SECOND=$(printenv NORMAL_VAR) + THIRD=$(printenv NORMAL_VAR) + echo "First: [$FIRST]" + echo "Second: [$SECOND]" + echo "Third: [$THIRD]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + enableChroot: true, + env: { + NORMAL_VAR: 'chroot_not_a_token', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First: [chroot_not_a_token]'); + expect(result.stdout).toContain('Second: [chroot_not_a_token]'); + expect(result.stdout).toContain('Third: [chroot_not_a_token]'); + expect(result.stderr).not.toContain('[one-shot-token] Token NORMAL_VAR'); + }, 120000); + + test('should handle multiple different tokens independently in chroot mode', async () => { + const testScript = ` + GITHUB_FIRST=$(printenv GITHUB_TOKEN) + GITHUB_SECOND=$(printenv GITHUB_TOKEN) + OPENAI_FIRST=$(printenv OPENAI_API_KEY) + OPENAI_SECOND=$(printenv OPENAI_API_KEY) + echo "GitHub first: [$GITHUB_FIRST]" + echo "GitHub second: [$GITHUB_SECOND]" + echo "OpenAI first: [$OPENAI_FIRST]" + echo "OpenAI second: [$OPENAI_SECOND]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + enableChroot: true, + env: { + GITHUB_TOKEN: 'ghp_chroot_multi_1', + OPENAI_API_KEY: 'sk-chroot-multi-2', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('GitHub first: [ghp_chroot_multi_1]'); + expect(result.stdout).toContain('GitHub second: []'); + expect(result.stdout).toContain('OpenAI first: [sk-chroot-multi-2]'); + expect(result.stdout).toContain('OpenAI second: []'); + }, 120000); + }); + + describe('Edge Cases', () => { + test('should handle token with empty value', async () => { + const testScript = ` + FIRST=$(printenv GITHUB_TOKEN) + SECOND=$(printenv GITHUB_TOKEN) + echo "First: [$FIRST]" + echo "Second: [$SECOND]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + env: { + GITHUB_TOKEN: '', + }, + } + ); + + expect(result).toSucceed(); + // Empty token should be treated as no token + expect(result.stdout).toContain('First: []'); + expect(result.stdout).toContain('Second: []'); + }, 120000); + + test('should handle token that is not set', async () => { + const testScript = ` + FIRST=$(printenv NONEXISTENT_TOKEN) + SECOND=$(printenv NONEXISTENT_TOKEN) + echo "First: [$FIRST]" + echo "Second: [$SECOND]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + } + ); + + expect(result).toSucceed(); + // Nonexistent token should return empty on both reads + expect(result.stdout).toContain('First: []'); + expect(result.stdout).toContain('Second: []'); + }, 120000); + + test('should handle token with special characters', async () => { + const testScript = ` + FIRST=$(printenv GITHUB_TOKEN) + SECOND=$(printenv GITHUB_TOKEN) + echo "First: [$FIRST]" + echo "Second: [$SECOND]" + `; + + const result = await runner.runWithSudo( + testScript, + { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + buildLocal: true, + env: { + GITHUB_TOKEN: 'ghp_test-with-special_chars@#$%', + }, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('First: [ghp_test-with-special_chars@#$%]'); + expect(result.stdout).toContain('Second: []'); + }, 120000); + }); +}); From 1d1d72773274dadba3bb6a52f66b86f55e328aa0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:32:08 -0800 Subject: [PATCH 07/11] fix: preserve secure_getenv semantics in one-shot token interposer (#610) * Initial plan * fix: implement proper secure_getenv with dlsym fallback Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: add thread safety to secure_getenv initialization Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> Co-authored-by: Landon Cox --- .../agent/one-shot-token/one-shot-token.c | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index 6cede43bf..c80a8f3ed 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -128,11 +128,62 @@ char *getenv(const char *name) { } /** - * Also intercept secure_getenv for completeness - * (some security-conscious code uses this instead of getenv) + * Intercepted secure_getenv function + * + * This function preserves secure_getenv semantics (returns NULL in privileged contexts) + * while applying the same one-shot token protection as getenv. + * + * For sensitive tokens: + * - First call: returns the real value (if not in privileged context), then unsets the variable + * - Subsequent calls: returns NULL + * + * For all other variables: passes through to real secure_getenv (or getenv if unavailable) */ char *secure_getenv(const char *name) { - /* secure_getenv returns NULL if the program is running with elevated privileges. - * We delegate to our intercepted getenv which handles the one-shot logic. */ - return getenv(name); + init_real_secure_getenv(); + init_real_getenv(); + + /* If secure_getenv is not available, fall back to our intercepted getenv */ + if (real_secure_getenv == NULL) { + return getenv(name); + } + + int token_idx = get_token_index(name); + + /* Not a sensitive token - pass through to real secure_getenv */ + if (token_idx < 0) { + return real_secure_getenv(name); + } + + /* Sensitive token - handle one-shot access with secure_getenv semantics */ + pthread_mutex_lock(&token_mutex); + + char *result = NULL; + + if (!token_accessed[token_idx]) { + /* First access - get the real value using secure_getenv */ + result = real_secure_getenv(name); + + if (result != NULL) { + /* Make a copy since unsetenv will invalidate the pointer */ + /* Note: This memory is intentionally never freed - it must persist + * for the lifetime of the caller's use of the returned pointer */ + result = strdup(result); + + /* Unset the variable so it can't be accessed again */ + unsetenv(name); + + fprintf(stderr, "[one-shot-token] Token %s accessed and cleared (via secure_getenv)\n", name); + } + + /* Mark as accessed even if NULL (prevents repeated log messages) */ + token_accessed[token_idx] = 1; + } else { + /* Already accessed - return NULL */ + result = NULL; + } + + pthread_mutex_unlock(&token_mutex); + + return result; } From 56f6c7c4a4ad19e0bc4418996f6a56aa3e9f863f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:35:56 +0000 Subject: [PATCH 08/11] Initial plan From 4ded23ac95f73f4d9baa71890ad7fbed1de9555c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:39:37 +0000 Subject: [PATCH 09/11] fix: add missing secure_getenv initialization code Add missing declarations and initialization functions for real_secure_getenv: - Add real_secure_getenv function pointer declaration - Add secure_getenv_init_once pthread_once control - Add init_real_secure_getenv_once() initialization function - Add init_real_secure_getenv() wrapper function This fixes the compilation error in the Docker build where secure_getenv was being called before its function pointer was initialized. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .../agent/one-shot-token/one-shot-token.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index c80a8f3ed..459686a35 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -45,8 +45,12 @@ static pthread_mutex_t token_mutex = PTHREAD_MUTEX_INITIALIZER; /* Pointer to the real getenv function */ static char *(*real_getenv)(const char *name) = NULL; +/* Pointer to the real secure_getenv function */ +static char *(*real_secure_getenv)(const char *name) = NULL; + /* pthread_once control for thread-safe initialization */ static pthread_once_t getenv_init_once = PTHREAD_ONCE_INIT; +static pthread_once_t secure_getenv_init_once = PTHREAD_ONCE_INIT; /* Initialize the real getenv pointer (called exactly once via pthread_once) */ static void init_real_getenv_once(void) { @@ -63,6 +67,20 @@ static void init_real_getenv(void) { pthread_once(&getenv_init_once, init_real_getenv_once); } +/* Initialize the real secure_getenv pointer (called exactly once via pthread_once) */ +static void init_real_secure_getenv_once(void) { + real_secure_getenv = dlsym(RTLD_NEXT, "secure_getenv"); + /* secure_getenv may not exist on all systems - this is OK, we'll fall back to getenv */ + if (real_secure_getenv == NULL) { + fprintf(stderr, "[one-shot-token] INFO: secure_getenv not available, will fall back to getenv\n"); + } +} + +/* Ensure real_secure_getenv is initialized (thread-safe) */ +static void init_real_secure_getenv(void) { + pthread_once(&secure_getenv_init_once, init_real_secure_getenv_once); +} + /* Check if a variable name is a sensitive token */ static int get_token_index(const char *name) { if (name == NULL) return -1; From 4e285c6ee7ff3262195e86c29109779741c587c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:41:06 +0000 Subject: [PATCH 10/11] docs: update progress - all fixes complete Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .../agent/one-shot-token/one-shot-token.so | Bin 0 -> 16616 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100755 containers/agent/one-shot-token/one-shot-token.so diff --git a/containers/agent/one-shot-token/one-shot-token.so b/containers/agent/one-shot-token/one-shot-token.so new file mode 100755 index 0000000000000000000000000000000000000000..ee4a6ec887bfbac74f4f1ad843fec70dc87cfc76 GIT binary patch literal 16616 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 literal 0 HcmV?d00001 From 821f4f2e334bf5f9140b5c7f8cbeffed6a3d168b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:41:22 +0000 Subject: [PATCH 11/11] chore: remove compiled binary from repository The one-shot-token.so file is generated during Docker build and should not be committed to the repository. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .../agent/one-shot-token/one-shot-token.so | Bin 16616 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100755 containers/agent/one-shot-token/one-shot-token.so diff --git a/containers/agent/one-shot-token/one-shot-token.so b/containers/agent/one-shot-token/one-shot-token.so deleted file mode 100755 index ee4a6ec887bfbac74f4f1ad843fec70dc87cfc76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16616 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