Skip to content
5 changes: 3 additions & 2 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,9 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
# This provides dynamic /proc/self/exe resolution (required by .NET CLR, JVM, and other
# runtimes that read /proc/self/exe to find themselves). A static bind mount of /proc/self
# always resolves to the parent shell's exe, causing runtime failures.
# Security: This procfs is container-scoped (only shows container processes, not host).
# SYS_ADMIN capability (required for mount) is dropped before user code runs.
# SECURITY: This creates a NEW procfs (mount -t proc), NOT a bind mount of host's /proc.
# Result: Container processes see only container PIDs, not host processes.
# The mount requires SYS_ADMIN capability (granted at container start, dropped before user code).
mkdir -p /host/proc
if mount -t proc -o nosuid,nodev,noexec proc /host/proc; then
echo "[entrypoint] Mounted procfs at /host/proc (nosuid,nodev,noexec)"
Expand Down
14 changes: 14 additions & 0 deletions containers/agent/one-shot-token/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,20 @@ Note: The `AWF_ONE_SHOT_TOKENS` variable must be exported before running `awf` s
- **In-process getenv() calls**: Since values are cached, any code in the same process can still call `getenv()` and get the cached token
- **Static linking**: Programs statically linked with libc bypass LD_PRELOAD
- **Direct syscalls**: Code that reads `/proc/self/environ` directly (without getenv) bypasses this protection
- **Task-level /proc exposure**: `/proc/PID/task/TID/environ` may still expose tokens even after `unsetenv()`. The library checks and logs warnings about this exposure.
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README now says the library "checks and logs warnings" for /proc/PID/task/TID/environ exposure, but the implementation added in src/lib.rs only scans the process environ pointer and does not read /proc/.../environ at all. Please either implement the /proc/*/task/*/environ checks, or update this section to avoid over-claiming the verification coverage.

Suggested change
- **Task-level /proc exposure**: `/proc/PID/task/TID/environ` may still expose tokens even after `unsetenv()`. The library checks and logs warnings about this exposure.
- **Task-level /proc exposure**: `/proc/PID/task/TID/environ` may still expose tokens even after `unsetenv()`. The current implementation does not detect or log this; treat task-level `/proc` environments as potentially exposed.

Copilot uses AI. Check for mistakes.

### Environment Verification

After calling `unsetenv()` to clear tokens, the library automatically verifies whether the token was successfully removed by directly checking the process's environment pointer. This works correctly in both regular and chroot modes.

**Log messages:**
- `INFO: Token <name> cleared from process environment` - Token successfully cleared (✓ secure)
- `WARNING: Token <name> still exposed in process environment` - Token still visible (⚠ security concern)
- `INFO: Token <name> cleared (environ is null)` - Environment pointer is null

This verification runs automatically after `unsetenv()` on first access to each sensitive token and helps identify potential security issues with environment exposure.

**Note on chroot mode:** The verification uses the process's `environ` pointer directly rather than reading from `/proc/self/environ`. This is necessary because in chroot mode, `/proc` may be bind-mounted from the host and show stale environment data.

### Defense in Depth

Expand Down
57 changes: 56 additions & 1 deletion containers/agent/one-shot-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ use std::ffi::{CStr, CString};
use std::ptr;
use std::sync::Mutex;

// External declaration of the environ pointer
// This is a POSIX standard global that points to the process's environment
extern "C" {
static mut environ: *mut *mut c_char;
}

/// Maximum number of tokens we can track
const MAX_TOKENS: usize = 100;

Expand Down Expand Up @@ -196,6 +202,52 @@ fn format_token_value(value: &str) -> String {
}
}

/// Check if a token still exists in the process environment
///
/// This function verifies whether unsetenv() successfully cleared the token
/// by directly checking the process's environ pointer. This works correctly
/// in both chroot and non-chroot modes (reading /proc/self/environ fails in
/// chroot because it shows the host's procfs, not the chrooted process's state).
fn check_task_environ_exposure(token_name: &str) {
Comment on lines +205 to +211
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new verification helper is named check_task_environ_exposure, but it only scans the process environ array and does not inspect /proc/self/task/*/environ (or any task-level data). This is misleading and makes it harder to understand what is actually being verified; rename it to reflect process-level checking or implement the intended task-level verification.

This issue also appears in the following locations of the same file:

  • line 206
  • line 324
  • line 326
Suggested change
/// Check if a token still exists in the process environment
///
/// This function verifies whether unsetenv() successfully cleared the token
/// by directly checking the process's environ pointer. This works correctly
/// in both chroot and non-chroot modes (reading /proc/self/environ fails in
/// chroot because it shows the host's procfs, not the chrooted process's state).
fn check_task_environ_exposure(token_name: &str) {
/// Check if a token still exists in the process environment (process-level check)
///
/// This function verifies whether unsetenv() successfully cleared the token
/// by directly checking the process's environ pointer. This works correctly
/// in both chroot and non-chroot modes (reading /proc/self/environ fails in
/// chroot because it shows the host's procfs, not the chrooted process's state).
fn check_process_environ_exposure(token_name: &str) {

Copilot uses AI. Check for mistakes.
// SAFETY: environ is a standard POSIX global that points to the process's environment.
// It's safe to read as long as we don't hold references across modifications.
// We're only reading it after unsetenv() has completed, so the pointer is stable.
unsafe {
let mut env_ptr = environ;
if env_ptr.is_null() {
eprintln!("[one-shot-token] INFO: Token {} cleared (environ is null)", token_name);
return;
}

// Iterate through environment variables
let token_prefix = format!("{}=", token_name);
let token_prefix_bytes = token_prefix.as_bytes();

while !(*env_ptr).is_null() {
let env_cstr = CStr::from_ptr(*env_ptr);
let env_bytes = env_cstr.to_bytes();

// Check if this entry starts with our token name
if env_bytes.len() >= token_prefix_bytes.len()
&& &env_bytes[..token_prefix_bytes.len()] == token_prefix_bytes {
eprintln!(
"[one-shot-token] WARNING: Token {} still exposed in process environment",
token_name
);
return;
}

env_ptr = env_ptr.add(1);
}

// Token not found in environment - success!
eprintln!(
"[one-shot-token] INFO: Token {} cleared from process environment",
token_name
);
}
}

/// Core implementation for cached token access
///
/// # Safety
Expand Down Expand Up @@ -268,9 +320,12 @@ unsafe fn handle_getenv_impl(
// Cache the pointer so subsequent reads return the same value
state.cache.insert(name_str.to_string(), cached);

// Unset the environment variable so /proc/self/environ is cleared
// Unset the environment variable so it's no longer accessible
libc::unsetenv(name);

// Verify the token was cleared from the process environment
check_task_environ_exposure(name_str);

let suffix = if via_secure { " (via secure_getenv)" } else { "" };
eprintln!(
"[one-shot-token] Token {} accessed and cached (value: {}){}",
Expand Down
2 changes: 2 additions & 0 deletions docs/chroot-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ As of v0.13.13, chroot mode mounts a fresh container-scoped procfs at `/host/pro

**Security implications:**
- The mounted procfs only exposes container processes, not host processes
- **SECURITY GUARANTEE**: No process inside the container can read the host's /proc filesystem
- The procfs mount is type `proc` (new filesystem), NOT a bind mount of the host's /proc
- Mount operation completes before user code runs (capability dropped)
- procfs is mounted with security restrictions: `nosuid,nodev,noexec`
- User code cannot unmount or remount (no `CAP_SYS_ADMIN`, umount blocked in seccomp)
Expand Down
13 changes: 7 additions & 6 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,12 +480,13 @@ export function generateDockerCompose(
agentVolumes.push('/opt:/host/opt:ro');

// Special filesystem mounts for chroot (needed for devices and runtime introspection)
// NOTE: /proc is NOT bind-mounted here. Instead, a fresh container-scoped procfs is
// mounted at /host/proc in entrypoint.sh via 'mount -t proc'. This provides:
// - Dynamic /proc/self/exe (required by .NET CLR and other runtimes)
// - /proc/cpuinfo, /proc/meminfo (required by JVM, .NET GC)
// - Container-scoped only (does not expose host process info)
// The mount requires SYS_ADMIN capability, which is dropped before user code runs.
// SECURITY: /proc is NOT bind-mounted from host. Instead, a fresh container-scoped
// procfs is mounted at /host/proc in entrypoint.sh via 'mount -t proc'. This ensures:
// - Container processes can access /proc/self/exe (required by .NET CLR, JVM)
// - /proc/cpuinfo, /proc/meminfo available (required by JVM, .NET GC)
// - ISOLATION: No process inside container can read host's /proc filesystem
// - Container-scoped procfs only shows container processes, not host processes
// - Mount requires SYS_ADMIN capability, which is dropped before user code runs
agentVolumes.push(
'/sys:/host/sys:ro', // Read-only sysfs
'/dev:/host/dev:ro', // Read-only device nodes (needed by some runtimes)
Expand Down
Loading