From e59410b847e6b15901c00348a8de8ea1278cd096 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 9 Feb 2026 20:55:13 -0800 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 5d0fe07ecfa36008bbb35a3b96cdafaf424ed0ea Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:41:01 -0800 Subject: [PATCH 8/9] feat: add runtime configuration for one-shot token protection via AWF_ONE_SHOT_TOKENS (#607) * Initial plan * feat: add runtime configuration for one-shot token list via AWF_ONE_SHOT_TOKENS Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: address code review feedback for one-shot token library - Make initialization thread-safe using existing mutex - Add cleanup of allocated tokens on memory allocation failure - Use isspace() for comprehensive whitespace trimming (handles newlines, tabs, etc.) Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: resolve race condition and buffer underflow in token parsing - Fix potential buffer underflow when trimming empty strings - Fix race condition by keeping mutex held during get_token_index() call - Ensure thread-safe access to token list during lookup Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * refactor: optimize strlen usage and add MAX_TOKENS documentation - Cache strlen result to avoid redundant computation - Add inline comment explaining MAX_TOKENS limit rationale Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * style: improve comment formatting for MAX_TOKENS Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: use strtok_r and fallback to defaults for empty token list - Replace strtok() with strtok_r() to avoid interfering with application code - Fall back to default token list if AWF_ONE_SHOT_TOKENS parses to zero tokens - Add warning messages when falling back due to misconfiguration Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * docs: update README with fallback behavior and strtok_r details Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * refactor: explicitly reset num_tokens in fallback path for clarity Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * docs: clarify defensive programming intent in num_tokens reset 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 --- containers/agent/one-shot-token/README.md | 74 +++++++++- .../agent/one-shot-token/one-shot-token.c | 130 ++++++++++++++++-- 2 files changed, 193 insertions(+), 11 deletions(-) diff --git a/containers/agent/one-shot-token/README.md b/containers/agent/one-shot-token/README.md index dc2c0b2a8..b00f09af7 100644 --- a/containers/agent/one-shot-token/README.md +++ b/containers/agent/one-shot-token/README.md @@ -6,9 +6,11 @@ The one-shot token library is an `LD_PRELOAD` shared library that provides **sin This protects against malicious code that might attempt to exfiltrate tokens after the legitimate application has already consumed them. -## Protected Environment Variables +## Configuration -The library intercepts access to these token variables: +### Default Protected Tokens + +By default, the library protects these token variables: **GitHub:** - `COPILOT_GITHUB_TOKEN` @@ -29,6 +31,26 @@ The library intercepts access to these token variables: **Codex:** - `CODEX_API_KEY` +### Custom Token List + +You can configure a custom list of tokens to protect using the `AWF_ONE_SHOT_TOKENS` environment variable: + +```bash +# Protect custom tokens instead of defaults +export AWF_ONE_SHOT_TOKENS="MY_API_KEY,MY_SECRET_TOKEN,CUSTOM_AUTH_KEY" + +# Run your command with the library preloaded +LD_PRELOAD=/usr/local/lib/one-shot-token.so ./your-program +``` + +**Important notes:** +- When `AWF_ONE_SHOT_TOKENS` is set with valid tokens, **only** those tokens are protected (defaults are not included) +- 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) +- Uses `strtok_r()` internally, which is thread-safe and won't interfere with application code using `strtok()` + ## How It Works ### The LD_PRELOAD Mechanism @@ -154,6 +176,8 @@ This produces `one-shot-token.so` in the current directory. ## Testing +### Basic Test (Default Tokens) + ```bash # Build the library ./build.sh @@ -184,11 +208,57 @@ LD_PRELOAD=./one-shot-token.so ./test_getenv Expected output: ``` +[one-shot-token] Initialized with 11 default token(s) [one-shot-token] Token GITHUB_TOKEN accessed and cleared First read: test-token-12345 Second read: ``` +### Custom Token Test + +```bash +# Build the library +./build.sh + +# Test with custom tokens +export AWF_ONE_SHOT_TOKENS="MY_API_KEY,SECRET_TOKEN" +export MY_API_KEY="secret-value-123" +export SECRET_TOKEN="another-secret" + +LD_PRELOAD=./one-shot-token.so bash -c ' + echo "First MY_API_KEY: $(printenv MY_API_KEY)" + echo "Second MY_API_KEY: $(printenv MY_API_KEY)" + echo "First SECRET_TOKEN: $(printenv SECRET_TOKEN)" + echo "Second SECRET_TOKEN: $(printenv SECRET_TOKEN)" +' +``` + +Expected output: +``` +[one-shot-token] Initialized with 2 custom token(s) from AWF_ONE_SHOT_TOKENS +[one-shot-token] Token MY_API_KEY accessed and cleared +First MY_API_KEY: secret-value-123 +Second MY_API_KEY: +[one-shot-token] Token SECRET_TOKEN accessed and cleared +First SECRET_TOKEN: another-secret +Second SECRET_TOKEN: +``` + +### Integration with AWF + +When using the library with AWF (Agentic Workflow Firewall): + +```bash +# Use default tokens +sudo awf --allow-domains github.com -- your-command + +# Use custom tokens +export AWF_ONE_SHOT_TOKENS="MY_TOKEN,CUSTOM_API_KEY" +sudo -E awf --allow-domains github.com -- your-command +``` + +Note: The `AWF_ONE_SHOT_TOKENS` variable must be exported before running `awf` so it's available when the library initializes. + ## Security Considerations ### What This Protects Against diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index c80a8f3ed..c6dba82ba 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -5,6 +5,10 @@ * On first access, returns the real value and immediately unsets the variable. * Subsequent calls return NULL, preventing token reuse by malicious code. * + * Configuration: + * AWF_ONE_SHOT_TOKENS - Comma-separated list of token names to protect + * If not set, uses built-in defaults + * * 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 */ @@ -15,9 +19,10 @@ #include #include #include +#include -/* Sensitive token environment variable names */ -static const char *SENSITIVE_TOKENS[] = { +/* Default sensitive token environment variable names */ +static const char *DEFAULT_SENSITIVE_TOKENS[] = { /* GitHub tokens */ "COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN", @@ -36,12 +41,24 @@ static const char *SENSITIVE_TOKENS[] = { NULL }; +/* Maximum number of tokens we can track (for static allocation). This limit + * balances memory usage with practical needs - 100 tokens should be more than + * sufficient for any reasonable use case while keeping memory overhead low. */ +#define MAX_TOKENS 100 + +/* Runtime token list (populated from AWF_ONE_SHOT_TOKENS or defaults) */ +static char *sensitive_tokens[MAX_TOKENS]; +static int num_tokens = 0; + /* Track which tokens have been accessed (one flag per token) */ -static int token_accessed[sizeof(SENSITIVE_TOKENS) / sizeof(SENSITIVE_TOKENS[0])] = {0}; +static int token_accessed[MAX_TOKENS] = {0}; /* Mutex for thread safety */ static pthread_mutex_t token_mutex = PTHREAD_MUTEX_INITIALIZER; +/* Initialization flag */ +static int tokens_initialized = 0; + /* Pointer to the real getenv function */ static char *(*real_getenv)(const char *name) = NULL; @@ -58,6 +75,95 @@ static void init_real_getenv_once(void) { } } +/** + * Initialize the token list from AWF_ONE_SHOT_TOKENS environment variable + * or use defaults if not set. This is called once at first getenv() call. + * Note: This function must be called with token_mutex held. + */ +static void init_token_list(void) { + if (tokens_initialized) { + return; + } + + /* Get the configuration from environment */ + const char *config = real_getenv("AWF_ONE_SHOT_TOKENS"); + + if (config != NULL && config[0] != '\0') { + /* Parse comma-separated token list using strtok_r for thread safety */ + char *config_copy = strdup(config); + if (config_copy == NULL) { + fprintf(stderr, "[one-shot-token] ERROR: Failed to allocate memory for token list\n"); + abort(); + } + + char *saveptr = NULL; + char *token = strtok_r(config_copy, ",", &saveptr); + while (token != NULL && num_tokens < MAX_TOKENS) { + /* Trim leading whitespace */ + while (*token && isspace((unsigned char)*token)) token++; + + /* Trim trailing whitespace (only if string is non-empty) */ + size_t token_len = strlen(token); + if (token_len > 0) { + char *end = token + token_len - 1; + while (end > token && isspace((unsigned char)*end)) { + *end = '\0'; + end--; + } + } + + if (*token != '\0') { + sensitive_tokens[num_tokens] = strdup(token); + if (sensitive_tokens[num_tokens] == NULL) { + fprintf(stderr, "[one-shot-token] ERROR: Failed to allocate memory for token name\n"); + /* Clean up previously allocated tokens */ + for (int i = 0; i < num_tokens; i++) { + free(sensitive_tokens[i]); + } + free(config_copy); + abort(); + } + num_tokens++; + } + + token = strtok_r(NULL, ",", &saveptr); + } + + free(config_copy); + + /* If AWF_ONE_SHOT_TOKENS was set but resulted in zero tokens (e.g., ",,," or whitespace only), + * fall back to defaults to avoid silently disabling all protection */ + if (num_tokens == 0) { + fprintf(stderr, "[one-shot-token] WARNING: AWF_ONE_SHOT_TOKENS was set but parsed to zero tokens\n"); + fprintf(stderr, "[one-shot-token] WARNING: Falling back to default token list to maintain protection\n"); + /* num_tokens is already 0 here; assignment is defensive programming for future refactoring */ + num_tokens = 0; + } else { + fprintf(stderr, "[one-shot-token] Initialized with %d custom token(s) from AWF_ONE_SHOT_TOKENS\n", num_tokens); + tokens_initialized = 1; + return; + } + } + + /* Use default token list (when AWF_ONE_SHOT_TOKENS is unset, empty, or parsed to zero tokens) */ + /* Note: num_tokens should be 0 when we reach here */ + for (int i = 0; DEFAULT_SENSITIVE_TOKENS[i] != NULL && num_tokens < MAX_TOKENS; i++) { + sensitive_tokens[num_tokens] = strdup(DEFAULT_SENSITIVE_TOKENS[i]); + if (sensitive_tokens[num_tokens] == NULL) { + fprintf(stderr, "[one-shot-token] ERROR: Failed to allocate memory for default token name\n"); + /* Clean up previously allocated tokens */ + for (int j = 0; j < num_tokens; j++) { + free(sensitive_tokens[j]); + } + abort(); + } + num_tokens++; + } + + fprintf(stderr, "[one-shot-token] Initialized with %d default token(s)\n", num_tokens); + + tokens_initialized = 1; +} /* Ensure real_getenv is initialized (thread-safe) */ static void init_real_getenv(void) { pthread_once(&getenv_init_once, init_real_getenv_once); @@ -67,8 +173,8 @@ static void init_real_getenv(void) { 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) { + for (int i = 0; i < num_tokens; i++) { + if (strcmp(name, sensitive_tokens[i]) == 0) { return i; } } @@ -87,16 +193,22 @@ static int get_token_index(const char *name) { char *getenv(const char *name) { init_real_getenv(); + /* Initialize token list on first call (thread-safe) */ + pthread_mutex_lock(&token_mutex); + if (!tokens_initialized) { + init_token_list(); + } + + /* Get token index while holding mutex to avoid race with initialization */ int token_idx = get_token_index(name); - /* Not a sensitive token - pass through */ + /* Not a sensitive token - release mutex and pass through */ if (token_idx < 0) { + pthread_mutex_unlock(&token_mutex); return real_getenv(name); } - /* Sensitive token - handle one-shot access */ - pthread_mutex_lock(&token_mutex); - + /* Sensitive token - handle one-shot access (mutex already held) */ char *result = NULL; if (!token_accessed[token_idx]) { From 16ad94982c14297e487ae618eeeb4c5d0cd96ae6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:05:14 -0800 Subject: [PATCH 9/9] fix: add missing secure_getenv interposer initialization (#619) * Initial plan * fix: add missing secure_getenv declarations and initialization Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * chore: remove build artifacts from git Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * docs: update progress - fix complete Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * chore: remove test files from git 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/one-shot-token/.gitignore | 1 + .../agent/one-shot-token/one-shot-token.c | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 containers/agent/one-shot-token/.gitignore diff --git a/containers/agent/one-shot-token/.gitignore b/containers/agent/one-shot-token/.gitignore new file mode 100644 index 000000000..140f8cf80 --- /dev/null +++ b/containers/agent/one-shot-token/.gitignore @@ -0,0 +1 @@ +*.so diff --git a/containers/agent/one-shot-token/one-shot-token.c b/containers/agent/one-shot-token/one-shot-token.c index c6dba82ba..8c4b6b47e 100644 --- a/containers/agent/one-shot-token/one-shot-token.c +++ b/containers/agent/one-shot-token/one-shot-token.c @@ -62,8 +62,12 @@ static int tokens_initialized = 0; /* 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) { @@ -75,6 +79,15 @@ static void init_real_getenv_once(void) { } } +/* 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"); + /* Note: secure_getenv may not be available on all systems, so we don't abort if NULL */ + if (real_secure_getenv == NULL) { + fprintf(stderr, "[one-shot-token] WARNING: secure_getenv not available, falling back to getenv\n"); + } +} + /** * Initialize the token list from AWF_ONE_SHOT_TOKENS environment variable * or use defaults if not set. This is called once at first getenv() call. @@ -169,6 +182,11 @@ static void init_real_getenv(void) { pthread_once(&getenv_init_once, init_real_getenv_once); } +/* 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;