Skip to content

Commit d08a18b

Browse files
authored
fix: use granular workspace mounting instead of entire HOME directory (#699)
1 parent 28ac9cd commit d08a18b

File tree

5 files changed

+376
-107
lines changed

5 files changed

+376
-107
lines changed

containers/agent/one-shot-token/one-shot-token.c

Lines changed: 48 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ static char *token_cache[MAX_TOKENS] = {0};
6262
/* Mutex for thread safety */
6363
static pthread_mutex_t token_mutex = PTHREAD_MUTEX_INITIALIZER;
6464

65+
/* Thread-local recursion guard to prevent deadlock when:
66+
* 1. secure_getenv("X") acquires token_mutex
67+
* 2. init_token_list() calls fprintf() for logging
68+
* 3. glibc's fprintf calls secure_getenv() for locale initialization
69+
* 4. Our secure_getenv() would try to acquire token_mutex again -> DEADLOCK
70+
*
71+
* With this guard, recursive calls from the same thread skip the mutex
72+
* and pass through directly to the real function. This is safe because
73+
* the recursive call is always for a non-sensitive variable (locale).
74+
*/
75+
static __thread int in_getenv = 0;
76+
6577
/* Initialization flag */
6678
static int tokens_initialized = 0;
6779

@@ -71,27 +83,21 @@ static char *(*real_getenv)(const char *name) = NULL;
7183
/* Pointer to the real secure_getenv function */
7284
static char *(*real_secure_getenv)(const char *name) = NULL;
7385

74-
/* pthread_once control for thread-safe initialization */
75-
static pthread_once_t getenv_init_once = PTHREAD_ONCE_INIT;
76-
static pthread_once_t secure_getenv_init_once = PTHREAD_ONCE_INIT;
77-
78-
/* Initialize the real getenv pointer (called exactly once via pthread_once) */
79-
static void init_real_getenv_once(void) {
86+
/* Resolve real_getenv if not yet resolved (idempotent, no locks needed) */
87+
static void ensure_real_getenv(void) {
88+
if (real_getenv != NULL) return;
8089
real_getenv = dlsym(RTLD_NEXT, "getenv");
8190
if (real_getenv == NULL) {
8291
fprintf(stderr, "[one-shot-token] FATAL: Could not find real getenv: %s\n", dlerror());
83-
/* Cannot recover - abort to prevent undefined behavior */
8492
abort();
8593
}
8694
}
8795

88-
/* Initialize the real secure_getenv pointer (called exactly once via pthread_once) */
89-
static void init_real_secure_getenv_once(void) {
96+
/* Resolve real_secure_getenv if not yet resolved (idempotent, no locks needed) */
97+
static void ensure_real_secure_getenv(void) {
98+
if (real_secure_getenv != NULL) return;
9099
real_secure_getenv = dlsym(RTLD_NEXT, "secure_getenv");
91-
/* Note: secure_getenv may not be available on all systems, so we don't abort if NULL */
92-
if (real_secure_getenv == NULL) {
93-
fprintf(stderr, "[one-shot-token] WARNING: secure_getenv not available, falling back to getenv\n");
94-
}
100+
/* secure_getenv may not be available on all systems - that's OK */
95101
}
96102

97103
/**
@@ -183,14 +189,20 @@ static void init_token_list(void) {
183189

184190
tokens_initialized = 1;
185191
}
186-
/* Ensure real_getenv is initialized (thread-safe) */
187-
static void init_real_getenv(void) {
188-
pthread_once(&getenv_init_once, init_real_getenv_once);
189-
}
190-
191-
/* Ensure real_secure_getenv is initialized (thread-safe) */
192-
static void init_real_secure_getenv(void) {
193-
pthread_once(&secure_getenv_init_once, init_real_secure_getenv_once);
192+
/**
193+
* Library constructor - resolves real getenv/secure_getenv at load time.
194+
*
195+
* This MUST run before any other library's constructors to prevent a deadlock:
196+
* if a constructor (e.g., LLVM in rustc) calls getenv() and we lazily call
197+
* dlsym(RTLD_NEXT) from within our intercepted getenv(), dlsym() deadlocks
198+
* because the dynamic linker's internal lock is already held during constructor
199+
* execution. Resolving here (in our LD_PRELOAD'd constructor which runs first)
200+
* avoids this entirely.
201+
*/
202+
__attribute__((constructor))
203+
static void one_shot_token_init(void) {
204+
ensure_real_getenv();
205+
ensure_real_secure_getenv();
194206
}
195207

196208
/* Check if a variable name is a sensitive token */
@@ -246,7 +258,13 @@ static const char *format_token_value(const char *value) {
246258
* For all other variables: passes through to real getenv
247259
*/
248260
char *getenv(const char *name) {
249-
init_real_getenv();
261+
ensure_real_getenv();
262+
263+
/* Skip interception during recursive calls (e.g., fprintf -> secure_getenv -> getenv) */
264+
if (in_getenv) {
265+
return real_getenv(name);
266+
}
267+
in_getenv = 1;
250268

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

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

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

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

295314
pthread_mutex_unlock(&token_mutex);
315+
in_getenv = 0;
296316

297317
return result;
298318
}
@@ -310,53 +330,12 @@ char *getenv(const char *name) {
310330
* For all other variables: passes through to real secure_getenv (or getenv if unavailable)
311331
*/
312332
char *secure_getenv(const char *name) {
313-
init_real_secure_getenv();
314-
init_real_getenv();
315-
316-
/* If secure_getenv is not available, fall back to our intercepted getenv */
333+
ensure_real_secure_getenv();
334+
ensure_real_getenv();
317335
if (real_secure_getenv == NULL) {
318336
return getenv(name);
319337
}
320-
321-
int token_idx = get_token_index(name);
322-
323-
/* Not a sensitive token - pass through to real secure_getenv */
324-
if (token_idx < 0) {
325-
return real_secure_getenv(name);
326-
}
327-
328-
/* Sensitive token - handle cached access with secure_getenv semantics */
329-
pthread_mutex_lock(&token_mutex);
330-
331-
char *result = NULL;
332-
333-
if (!token_accessed[token_idx]) {
334-
/* First access - get the real value using secure_getenv */
335-
result = real_secure_getenv(name);
336-
337-
if (result != NULL) {
338-
/* Cache the value so subsequent reads succeed after unsetenv */
339-
/* Note: This memory is intentionally never freed - it must persist
340-
* for the lifetime of the process */
341-
token_cache[token_idx] = strdup(result);
342-
343-
/* Unset the variable from the environment so /proc/self/environ is cleared */
344-
unsetenv(name);
345-
346-
fprintf(stderr, "[one-shot-token] Token %s accessed and cached (value: %s) (via secure_getenv)\n",
347-
name, format_token_value(token_cache[token_idx]));
348-
349-
result = token_cache[token_idx];
350-
}
351-
352-
/* Mark as accessed even if NULL (prevents repeated log messages) */
353-
token_accessed[token_idx] = 1;
354-
} else {
355-
/* Already accessed - return cached value */
356-
result = token_cache[token_idx];
357-
}
358-
359-
pthread_mutex_unlock(&token_mutex);
360-
361-
return result;
338+
/* Simple passthrough - no mutex, no token handling.
339+
* Token protection is handled by getenv() which is also intercepted. */
340+
return real_secure_getenv(name);
362341
}

docs/selective-mounting.md

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,23 @@
22

33
## Overview
44

5-
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.
5+
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.
6+
7+
## Security Fix (v0.14.1)
8+
9+
**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:
10+
- Overlays only work if the credential file exists on the host
11+
- Non-standard credential locations were not protected
12+
- Any new credential files would be accessible by default
13+
- Subdirectories with credentials (e.g., `~/.config/hub/config`) were fully accessible
14+
15+
**Fixed Implementation**: As of v0.14.1, AWF uses **granular mounting**:
16+
- Mount **only** the workspace directory (`$GITHUB_WORKSPACE` or current working directory)
17+
- Mount `~/.copilot/logs` separately for Copilot CLI logging
18+
- Apply `/dev/null` overlays as defense-in-depth
19+
- Never mount the entire `$HOME` directory
20+
21+
This eliminates the root cause by ensuring credential files in `$HOME` are never mounted at all.
622

723
## Threat Model: Prompt Injection Attacks
824

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

6076
### Selective Mounting
6177

62-
AWF uses chroot mode with selective path mounts. Credential files are hidden at the `/host` paths:
78+
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:
6379

6480
**What gets mounted:**
6581

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

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

177194
## Comparison: Before vs After
178195

179-
### Before (Blanket Mount)
196+
### Before Fix (v0.13.0-v0.14.0 - Vulnerable)
180197

181198
```yaml
182199
# docker-compose.yml
183200
services:
184201
agent:
185202
volumes:
186-
- /:/host:rw # ❌ Everything exposed
203+
- /home/runner:/home/runner:rw # ❌ Entire HOME exposed
204+
- /dev/null:/home/runner/.docker/config.json:ro # Attempted to hide with overlay
187205
```
188206
189-
**Attack succeeds:**
207+
**Attack succeeded:**
190208
```bash
191209
# Inside agent container
192-
$ cat ~/.docker/config.json
193-
{
194-
"auths": {
195-
"https://index.docker.io/v1/": {
196-
"auth": "Z2l0aHViYWN0aW9uczozZDY0NzJiOS0zZDQ5LTRkMTctOWZjOS05MGQyNDI1ODA0M2I="
197-
}
198-
}
199-
}
200-
# ❌ Credentials exposed!
210+
$ cat ~/.config/hub/config # Non-standard location, not in hardcoded overlay list
211+
oauth_token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
212+
# ❌ Credentials exposed! (file in HOME but not overlaid)
213+
214+
$ ls ~/.docker/
215+
config.json # exists but empty (overlaid)
216+
$ cat ~/.npmrc
217+
# (empty - overlaid)
218+
$ cat ~/.config/gh/hosts.yml
219+
# (empty - overlaid)
220+
221+
# But other locations are accessible:
222+
$ cat ~/.netrc
223+
machine github.com
224+
login my-username
225+
password my-personal-access-token
226+
# ❌ Credentials exposed! (not in hardcoded overlay list)
201227
```
202228

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

205231
```yaml
206232
# docker-compose.yml
207233
services:
208234
agent:
209235
volumes:
210-
- /tmp:/tmp:rw
211-
- /home/runner:/home/runner:rw
212-
- /dev/null:/home/runner/.docker/config.json:ro # ✓ Hidden
236+
- /home/runner/work/repo/repo:/home/runner/work/repo/repo:rw # ✓ Only workspace
237+
- /dev/null:/home/runner/.docker/config.json:ro # Defense-in-depth
213238
```
214239
215240
**Attack fails:**
216241
```bash
217242
# Inside agent container
218243
$ cat ~/.docker/config.json
219-
# (empty file - reads from /dev/null)
220-
# ✓ Credentials protected!
244+
cat: /home/runner/.docker/config.json: No such file or directory
245+
# ✓ Credentials protected! ($HOME not mounted)
246+
247+
$ cat ~/.config/hub/config
248+
cat: /home/runner/.config/hub/config: No such file or directory
249+
# ✓ Credentials protected! ($HOME not mounted)
250+
251+
$ cat ~/.npmrc
252+
cat: /home/runner/.npmrc: No such file or directory
253+
# ✓ Credentials protected! ($HOME not mounted)
254+
255+
$ cat ~/.netrc
256+
cat: /home/runner/.netrc: No such file or directory
257+
# ✓ Credentials protected! ($HOME not mounted)
258+
259+
$ ls ~/
260+
ls: cannot access '/home/runner/': No such file or directory
261+
# ✓ HOME directory not mounted at all!
221262
```
222263

223264
## Testing Security

0 commit comments

Comments
 (0)