Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1797da3
Initial plan
Claude Feb 11, 2026
4feb0ab
fix: use granular workspace mounting instead of entire HOME directory
Claude Feb 11, 2026
76c0c15
test: update docker-manager tests for granular workspace mounting
Claude Feb 11, 2026
740a42a
fix(docker): mount CLI state directories in chroot mode for Copilot a…
Copilot Feb 12, 2026
ec779ab
Merge remote-tracking branch 'origin/main' into claude/diagnose-firew…
Mossaka Feb 12, 2026
9f2c3c4
fix: remove noisy cached token logging and add Rust toolchain mounts
Mossaka Feb 12, 2026
754a155
fix: mount ~/.cargo as rw to allow /dev/null credential overlay
Mossaka Feb 12, 2026
50c8285
fix: mount ~/.rustup as rw and add ~/.npm for chroot mode
Mossaka Feb 12, 2026
40ee1aa
fix: pre-create chroot home subdirs with correct ownership
Mossaka Feb 12, 2026
2308287
test: add tests for chroot volume mounts and directory pre-creation
Mossaka Feb 12, 2026
1aee684
fix: ensure HOME directory has correct ownership in chroot mode
Mossaka Feb 12, 2026
246233e
fix: mount empty writable HOME directory for chroot mode
Mossaka Feb 12, 2026
0130718
fix: place chroot-home dir outside workDir to avoid tmpfs overlay
Mossaka Feb 12, 2026
bd96847
test: add cleanup test for chroot-home directory
Mossaka Feb 12, 2026
a686706
fix: resolve merge conflicts with origin/main
Mossaka Feb 12, 2026
46aaa37
fix: remove enableChroot from integration test (always-on now)
Mossaka Feb 12, 2026
3605d48
fix: merge origin/main, resolve docs conflicts
Mossaka Feb 12, 2026
b0c4923
fix: prevent one-shot-token deadlock with rustc/LLVM
Mossaka Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 48 additions & 69 deletions containers/agent/one-shot-token/one-shot-token.c
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ static char *token_cache[MAX_TOKENS] = {0};
/* Mutex for thread safety */
static pthread_mutex_t token_mutex = PTHREAD_MUTEX_INITIALIZER;

/* Thread-local recursion guard to prevent deadlock when:
* 1. secure_getenv("X") acquires token_mutex
* 2. init_token_list() calls fprintf() for logging
* 3. glibc's fprintf calls secure_getenv() for locale initialization
* 4. Our secure_getenv() would try to acquire token_mutex again -> DEADLOCK
*
* With this guard, recursive calls from the same thread skip the mutex
* and pass through directly to the real function. This is safe because
* the recursive call is always for a non-sensitive variable (locale).
*/
static __thread int in_getenv = 0;

/* Initialization flag */
static int tokens_initialized = 0;

Expand All @@ -71,27 +83,21 @@ 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) {
/* Resolve real_getenv if not yet resolved (idempotent, no locks needed) */
static void ensure_real_getenv(void) {
if (real_getenv != NULL) return;
real_getenv = dlsym(RTLD_NEXT, "getenv");
if (real_getenv == NULL) {
fprintf(stderr, "[one-shot-token] FATAL: Could not find real getenv: %s\n", dlerror());
/* Cannot recover - abort to prevent undefined behavior */
abort();
}
}

/* Initialize the real secure_getenv pointer (called exactly once via pthread_once) */
static void init_real_secure_getenv_once(void) {
/* Resolve real_secure_getenv if not yet resolved (idempotent, no locks needed) */
static void ensure_real_secure_getenv(void) {
if (real_secure_getenv != NULL) return;
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");
}
/* secure_getenv may not be available on all systems - that's OK */
}

/**
Expand Down Expand Up @@ -183,14 +189,20 @@ static void init_token_list(void) {

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);
}

/* 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);
/**
* Library constructor - resolves real getenv/secure_getenv at load time.
*
* This MUST run before any other library's constructors to prevent a deadlock:
* if a constructor (e.g., LLVM in rustc) calls getenv() and we lazily call
* dlsym(RTLD_NEXT) from within our intercepted getenv(), dlsym() deadlocks
* because the dynamic linker's internal lock is already held during constructor
* execution. Resolving here (in our LD_PRELOAD'd constructor which runs first)
* avoids this entirely.
*/
__attribute__((constructor))
static void one_shot_token_init(void) {
ensure_real_getenv();
ensure_real_secure_getenv();
}

/* Check if a variable name is a sensitive token */
Expand Down Expand Up @@ -246,7 +258,13 @@ static const char *format_token_value(const char *value) {
* For all other variables: passes through to real getenv
*/
char *getenv(const char *name) {
init_real_getenv();
ensure_real_getenv();

/* Skip interception during recursive calls (e.g., fprintf -> secure_getenv -> getenv) */
if (in_getenv) {
return real_getenv(name);
}
in_getenv = 1;

/* Initialize token list on first call (thread-safe) */
pthread_mutex_lock(&token_mutex);
Expand All @@ -260,6 +278,7 @@ char *getenv(const char *name) {
/* Not a sensitive token - release mutex and pass through */
if (token_idx < 0) {
pthread_mutex_unlock(&token_mutex);
in_getenv = 0;
return real_getenv(name);
}

Expand All @@ -279,7 +298,7 @@ char *getenv(const char *name) {
/* Unset the variable from the environment so /proc/self/environ is cleared */
unsetenv(name);

fprintf(stderr, "[one-shot-token] Token %s accessed and cached (value: %s)\n",
fprintf(stderr, "[one-shot-token] Token %s accessed and cached (value: %s)\n",
name, format_token_value(token_cache[token_idx]));

result = token_cache[token_idx];
Expand All @@ -293,6 +312,7 @@ char *getenv(const char *name) {
}

pthread_mutex_unlock(&token_mutex);
in_getenv = 0;

return result;
}
Expand All @@ -310,53 +330,12 @@ char *getenv(const char *name) {
* For all other variables: passes through to real secure_getenv (or getenv if unavailable)
*/
char *secure_getenv(const char *name) {
init_real_secure_getenv();
init_real_getenv();

/* If secure_getenv is not available, fall back to our intercepted getenv */
ensure_real_secure_getenv();
ensure_real_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 cached 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) {
/* Cache the value so subsequent reads succeed after unsetenv */
/* Note: This memory is intentionally never freed - it must persist
* for the lifetime of the process */
token_cache[token_idx] = strdup(result);

/* Unset the variable from the environment so /proc/self/environ is cleared */
unsetenv(name);

fprintf(stderr, "[one-shot-token] Token %s accessed and cached (value: %s) (via secure_getenv)\n",
name, format_token_value(token_cache[token_idx]));

result = token_cache[token_idx];
}

/* Mark as accessed even if NULL (prevents repeated log messages) */
token_accessed[token_idx] = 1;
} else {
/* Already accessed - return cached value */
result = token_cache[token_idx];
}

pthread_mutex_unlock(&token_mutex);

return result;
/* Simple passthrough - no mutex, no token handling.
* Token protection is handled by getenv() which is also intercepted. */
return real_secure_getenv(name);
}
81 changes: 61 additions & 20 deletions docs/selective-mounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,23 @@

## Overview

AWF implements **selective mounting** to protect against credential exfiltration via prompt injection attacks. Instead of mounting the entire host filesystem (`/:/host:rw`), only essential directories are mounted, and sensitive credential files are explicitly hidden.
AWF implements **granular selective mounting** to protect against credential exfiltration via prompt injection attacks. Instead of mounting the entire host filesystem or home directory, only the workspace directory and essential paths are mounted, and sensitive credential files are explicitly hidden.

## Security Fix (v0.14.1)

**Previous Vulnerability**: The initial selective mounting implementation (v0.13.0-v0.14.0) mounted the entire `$HOME` directory and attempted to hide credentials using `/dev/null` overlays. This approach had critical flaws:
- Overlays only work if the credential file exists on the host
- Non-standard credential locations were not protected
- Any new credential files would be accessible by default
- Subdirectories with credentials (e.g., `~/.config/hub/config`) were fully accessible

**Fixed Implementation**: As of v0.14.1, AWF uses **granular mounting**:
- Mount **only** the workspace directory (`$GITHUB_WORKSPACE` or current working directory)
- Mount `~/.copilot/logs` separately for Copilot CLI logging
- Apply `/dev/null` overlays as defense-in-depth
- Never mount the entire `$HOME` directory

This eliminates the root cause by ensuring credential files in `$HOME` are never mounted at all.

## Threat Model: Prompt Injection Attacks

Expand Down Expand Up @@ -59,7 +75,7 @@ The agent's legitimate tools (Read, Bash) become attack vectors when credentials

### Selective Mounting

AWF uses chroot mode with selective path mounts. Credential files are hidden at the `/host` paths:
AWF uses chroot mode with granular selective mounting. Instead of mounting the entire `$HOME`, an empty writable home directory is mounted with only specific subdirectories (`.cargo`, `.claude`, `.config`, etc.) overlaid on top. Credential files are hidden via `/dev/null` overlays as defense-in-depth:

**What gets mounted:**

Expand All @@ -85,6 +101,7 @@ const chrootVolumes = [
'/etc/passwd:/host/etc/passwd:ro',
'/etc/group:/host/etc/group:ro',
];
// Note: $HOME itself is NOT mounted, preventing access to credential directories
```

**What gets hidden:**
Expand Down Expand Up @@ -176,48 +193,72 @@ sudo awf --allow-full-filesystem-access --allow-domains github.com -- my-command

## Comparison: Before vs After

### Before (Blanket Mount)
### Before Fix (v0.13.0-v0.14.0 - Vulnerable)

```yaml
# docker-compose.yml
services:
agent:
volumes:
- /:/host:rw # ❌ Everything exposed
- /home/runner:/home/runner:rw # ❌ Entire HOME exposed
- /dev/null:/home/runner/.docker/config.json:ro # Attempted to hide with overlay
```

**Attack succeeds:**
**Attack succeeded:**
```bash
# Inside agent container
$ cat ~/.docker/config.json
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "Z2l0aHViYWN0aW9uczozZDY0NzJiOS0zZDQ5LTRkMTctOWZjOS05MGQyNDI1ODA0M2I="
}
}
}
# ❌ Credentials exposed!
$ cat ~/.config/hub/config # Non-standard location, not in hardcoded overlay list
oauth_token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ❌ Credentials exposed! (file in HOME but not overlaid)

$ ls ~/.docker/
config.json # exists but empty (overlaid)
$ cat ~/.npmrc
# (empty - overlaid)
$ cat ~/.config/gh/hosts.yml
# (empty - overlaid)

# But other locations are accessible:
$ cat ~/.netrc
machine github.com
login my-username
password my-personal-access-token
# ❌ Credentials exposed! (not in hardcoded overlay list)
```

### After (Selective Mount)
### After Fix (v0.14.1+ - Secure)

```yaml
# docker-compose.yml
services:
agent:
volumes:
- /tmp:/tmp:rw
- /home/runner:/home/runner:rw
- /dev/null:/home/runner/.docker/config.json:ro # ✓ Hidden
- /home/runner/work/repo/repo:/home/runner/work/repo/repo:rw # ✓ Only workspace
- /dev/null:/home/runner/.docker/config.json:ro # Defense-in-depth
```

**Attack fails:**
```bash
# Inside agent container
$ cat ~/.docker/config.json
# (empty file - reads from /dev/null)
# ✓ Credentials protected!
cat: /home/runner/.docker/config.json: No such file or directory
# ✓ Credentials protected! ($HOME not mounted)

$ cat ~/.config/hub/config
cat: /home/runner/.config/hub/config: No such file or directory
# ✓ Credentials protected! ($HOME not mounted)

$ cat ~/.npmrc
cat: /home/runner/.npmrc: No such file or directory
# ✓ Credentials protected! ($HOME not mounted)

$ cat ~/.netrc
cat: /home/runner/.netrc: No such file or directory
# ✓ Credentials protected! ($HOME not mounted)

$ ls ~/
ls: cannot access '/home/runner/': No such file or directory
# ✓ HOME directory not mounted at all!
```

## Testing Security
Expand Down
Loading
Loading