Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
89 changes: 71 additions & 18 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2497,9 +2497,51 @@ impl BashBuilder {
result
}

/// Sensitive host paths that are blocked from mounting by default.
/// THREAT[TM-FS-013]: Host prefixes refused as `RealFs` mount targets unless
/// the embedder explicitly allowlists a narrower path under them. Mounting
/// any of these (or a child of them) exposes broad system / kernel /
/// secrets surface to sandboxed scripts via a single mount call.
#[cfg(feature = "realfs")]
const SENSITIVE_MOUNT_PATHS: &[&str] = &["/etc/shadow", "/etc/sudoers", "/proc", "/sys"];
const SENSITIVE_MOUNT_PATHS: &[&str] = &[
// Kernel and pseudo-filesystems
"/proc", "/sys", "/dev", // System configuration / secret stores
"/etc", "/boot", // Privileged user directories (whole tree, not just secrets)
"/root", // User home roots — refuse the whole tree; embedder must narrow.
"/Users", "/home", // Runtime / sockets / pid dirs (host IPC surface)
"/run", "/var/run", // macOS canonicalized roots that mirror the above
"/private",
];

/// THREAT[TM-FS-013]: Path components that always indicate a secret-bearing
/// directory regardless of where they live (typically inside a user home).
/// Any mount whose canonicalized path contains one of these as a component
/// is refused unless explicitly allowlisted.
#[cfg(feature = "realfs")]
const SENSITIVE_PATH_COMPONENTS: &[&str] =
&[".ssh", ".aws", ".kube", ".docker", ".gnupg", ".gcloud"];

/// Returns `true` if `host_path` (already canonicalized) is a sensitive
/// mount target — either the host root itself, a path under one of the
/// `SENSITIVE_MOUNT_PATHS` prefixes, or a path containing a known secret
/// directory component.
#[cfg(feature = "realfs")]
fn is_sensitive_mount_path(host_path: &Path) -> bool {
// Refuse mounting the host root outright. `starts_with("/")` matches
// everything so the prefix check below cannot express this.
if host_path == Path::new("/") {
return true;
}
if Self::SENSITIVE_MOUNT_PATHS
.iter()
.any(|s| host_path.starts_with(Path::new(s)))
{
return true;
}
host_path.components().any(|c| {
let s = c.as_os_str();
Self::SENSITIVE_PATH_COMPONENTS.iter().any(|sec| s == *sec)
})
}

#[cfg(feature = "realfs")]
fn apply_real_mounts(
Expand Down Expand Up @@ -2551,26 +2593,37 @@ impl BashBuilder {
}
};

// Block sensitive paths
if Self::SENSITIVE_MOUNT_PATHS
.iter()
.any(|s| canonical_host.starts_with(Path::new(s)))
{
eprintln!(
"bashkit: warning: refusing to mount sensitive path {}",
m.host_path.display()
);
continue;
}
// THREAT[TM-FS-013]: Sensitive paths are refused by default. They
// can still be mounted by adding an explicit `allowed_mount_paths`
// entry that covers them — this turns the embedder's intent into
// an audit-visible decision instead of silent permissiveness.
let is_sensitive = Self::is_sensitive_mount_path(&canonical_host);

// Check allowlist if configured
if let Some(allowlist) = &canonical_allowlist
&& !allowlist
if let Some(allowlist) = &canonical_allowlist {
if !allowlist
.iter()
.any(|allowed| canonical_host.starts_with(allowed))
{
{
eprintln!(
"bashkit: warning: mount path {} not in allowlist, skipping",
m.host_path.display()
);
continue;
}
// Allowlisted: caller has accepted the risk explicitly. Still
// emit a stronger warning when the path is also sensitive so
// the trust-boundary break is visible in logs.
if is_sensitive {
eprintln!(
"bashkit: warning: mounting sensitive path {} via explicit allowlist — \
host trust boundary intentionally broken",
m.host_path.display()
);
}
} else if is_sensitive {
eprintln!(
"bashkit: warning: mount path {} not in allowlist, skipping",
"bashkit: warning: refusing to mount sensitive path {} (no allowlist set; \
pass an explicit `allowed_mount_paths` entry to override)",
m.host_path.display()
);
continue;
Expand Down
110 changes: 110 additions & 0 deletions crates/bashkit/tests/realfs_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,116 @@ async fn mount_sensitive_path_blocked() {
);
}

/// THREAT[TM-FS-013]: Each broad host root must be refused without an
/// explicit `allowed_mount_paths` opt-in. Each path is tested independently
/// because canonicalization can fail for some on a given host (e.g. /Users
/// only exists on macOS). A path that doesn't exist canonicalizes to an
/// error and the mount is skipped before reaching the sensitive-path check;
/// that is also a refusal, so the regression invariant holds either way.
#[tokio::test]
async fn mount_root_filesystem_blocked_without_allowlist() {
let mut bash = Bash::builder()
.mount_real_readonly_at("/", "/mnt/host")
.build();
let r = bash.exec("ls /mnt/host 2>&1; echo $?").await.unwrap();
assert!(
r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"),
"Mounting / must be refused without allowlist, got: {}",
r.stdout
);
}

#[tokio::test]
async fn mount_etc_blocked_without_allowlist() {
let mut bash = Bash::builder()
.mount_real_readonly_at("/etc", "/mnt/etc")
.build();
let r = bash.exec("ls /mnt/etc 2>&1; echo $?").await.unwrap();
assert!(
r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"),
"Mounting /etc must be refused without allowlist, got: {}",
r.stdout
);
}

#[tokio::test]
async fn mount_dev_blocked_without_allowlist() {
let mut bash = Bash::builder()
.mount_real_readonly_at("/dev", "/mnt/dev")
.build();
let r = bash.exec("ls /mnt/dev 2>&1; echo $?").await.unwrap();
assert!(
r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"),
"Mounting /dev must be refused without allowlist, got: {}",
r.stdout
);
}

#[tokio::test]
async fn mount_sys_blocked_without_allowlist() {
let mut bash = Bash::builder()
.mount_real_readonly_at("/sys", "/mnt/sys")
.build();
let r = bash.exec("ls /mnt/sys 2>&1; echo $?").await.unwrap();
assert!(
r.stdout.trim().ends_with('1') || r.stdout.contains("No such file"),
"Mounting /sys must be refused without allowlist, got: {}",
r.stdout
);
}

#[cfg(unix)]
#[tokio::test]
async fn mount_secret_dir_component_blocked_without_allowlist() {
use std::os::unix::fs::PermissionsExt;
// Create a fake .ssh directory inside a sandbox and try to mount it.
// The path component check must refuse it regardless of where it lives.
let sandbox = tempfile::tempdir().unwrap();
let secret_dir = sandbox.path().join(".ssh");
std::fs::create_dir_all(&secret_dir).unwrap();
let key_path = secret_dir.join("id_rsa");
std::fs::write(&key_path, "PRIVATE KEY").unwrap();
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();

let mut bash = Bash::builder()
.mount_real_readonly_at(&secret_dir, "/mnt/keys")
.build();
let r = bash
.exec("cat /mnt/keys/id_rsa 2>&1; echo $?")
.await
.unwrap();
assert!(
!r.stdout.contains("PRIVATE KEY"),
"Mounting a path containing .ssh must be refused, got: {}",
r.stdout
);
}

/// THREAT[TM-FS-013]: An explicit `allowed_mount_paths` opt-in is the
/// documented escape hatch. When the embedder allowlists a sensitive path,
/// the mount succeeds (the trust-boundary break is intentional and visible).
#[cfg(unix)]
#[tokio::test]
async fn mount_secret_dir_component_allowed_via_explicit_allowlist() {
let sandbox = tempfile::tempdir().unwrap();
let secret_dir = sandbox.path().join(".aws");
std::fs::create_dir_all(&secret_dir).unwrap();
let cfg = secret_dir.join("config");
std::fs::write(&cfg, "[default]\nregion=us-east-1\n").unwrap();

let canonical = std::fs::canonicalize(&secret_dir).unwrap();
let mut bash = Bash::builder()
.allowed_mount_paths([&canonical])
.mount_real_readonly_at(&secret_dir, "/mnt/aws")
.build();
let r = bash.exec("cat /mnt/aws/config 2>&1").await.unwrap();
assert!(
r.stdout.contains("us-east-1"),
"Explicit allowlist must allow the mount, got: {}",
r.stdout
);
}

#[tokio::test]
async fn mount_allowlist_blocks_dotdot_escape() {
let sandbox = tempfile::tempdir().unwrap();
Expand Down
1 change: 1 addition & 0 deletions specs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ max_parser_operations: 100_000, // Parser fuel (TM-DOS-024)
| TM-ESC-003 | Real FS access | Direct syscalls | No real FS by default; `RealFs` canonicalizes existing paths and nearest existing ancestors before attaching missing suffixes | **MITIGATED** |
| TM-ESC-004 | Mount escape | Mount real paths | MountableFs controlled | **MITIGATED** |
| TM-ESC-016 | Symlink escape via overlay rename | `ln -s /etc/passwd x; mv x y` | Overlay rename/copy preserve symlinks as symlinks | **FIXED** |
| TM-FS-013 | Permissive RealFs mount default | `mount_real_readonly_at("/", …)` exposes whole host without `allowed_mount_paths` | Allowlist-first: `/`, `/etc`, `/root`, `/Users`, `/home`, `/dev`, `/proc`, `/sys`, `/run`, `/var/run`, `/boot`, `/private`, and any path component matching `.ssh`, `.aws`, `.kube`, `.docker`, `.gnupg`, `.gcloud` are refused unless explicitly allowlisted | **MITIGATED** |

**Current Risk**: MEDIUM - Two open escape vectors (TM-ESC-012, TM-ESC-013) need remediation

Expand Down
Loading