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
10 changes: 6 additions & 4 deletions crates/nexus-core/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ use directories::ProjectDirs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};

/// Resolved filesystem locations, honoring the `NEXUS_CACHE_DIR` env override
/// documented in the proposal (config lives at ~/.config, data at ~/.local/share
/// unless overridden).
/// Resolved filesystem locations, honoring the `NEXUS_CACHE_DIR` and
/// `NEXUS_CONFIG_DIR` env overrides documented in the proposal (config lives
/// at ~/.config, data at ~/.local/share unless overridden).
pub struct Paths {
pub config_dir: PathBuf,
pub data_dir: PathBuf,
Expand All @@ -20,7 +20,9 @@ impl Paths {
let dirs = ProjectDirs::from("", "", "nexuscontext")
.expect("could not determine a home directory for the current user");

let config_dir = dirs.config_dir().to_path_buf();
let config_dir = std::env::var_os("NEXUS_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| dirs.config_dir().to_path_buf());
let data_dir = std::env::var_os("NEXUS_CACHE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| dirs.data_dir().to_path_buf());
Expand Down
115 changes: 48 additions & 67 deletions crates/nexus-index/tests/path_security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,41 @@
//! `call_graph_dot`, `run_query`/`run_cypher_query`).
//!
//! `nexus_core::Paths::resolve()` (what every one of these functions calls
//! internally) resolves its config directory from `$HOME` on Unix via the
//! `directories` crate - there's no injectable override, so these tests
//! redirect `HOME` to a scratch directory for the duration of each test and
//! write a `config.toml` there with a controlled `allowed_roots`. That env
//! var is process-global, so `ENV_LOCK` below serializes every test in this
//! binary against it (integration test binaries otherwise run their `#[test]`
//! functions on multiple threads).
//! internally) honors a `NEXUS_CONFIG_DIR` env override (issue #85) ahead of
//! its normal `directories`-crate-based resolution, so these tests point
//! that var directly at a scratch directory for the duration of each test
//! and write a `config.toml` there with a controlled `allowed_roots`. That
//! env var is process-global, so `ENV_LOCK` below serializes every test in
//! this binary against it (integration test binaries otherwise run their
//! `#[test]` functions on multiple threads).
//!
//! ## Why this whole file, not just the symlink cases, is `#[cfg(unix)]`
//! ## Platform coverage (issues #83, #85)
//!
//! Investigated for issue #83: it's tempting to assume only the
//! `std::os::unix::fs::symlink` cases below need Unix and the rest (outside-
//! root rejection, `..`-traversal, the confused-deputy case) could run
//! unconditionally. They can't, today - every single test in this file,
//! symlink or not, goes through `setup_fake_home`/`FakeHome`, which only
//! works because `directories::ProjectDirs`'s *Unix* backends resolve
//! through `$HOME`/`$XDG_CONFIG_HOME`. Its Windows backend
//! Before `NEXUS_CONFIG_DIR` existed, this whole file was `#[cfg(unix)]`:
//! every test went through `setup_fake_home`/`FakeHome`, which redirected
//! `$HOME`/`$XDG_CONFIG_HOME` and relied on `directories::ProjectDirs`'s
//! *Unix* backends resolving through them. Its Windows backend
//! (`directories-5.0.1/src/win.rs`) calls
//! `dirs_sys::known_folder_roaming_app_data()`, which goes through the Win32
//! known-folder API (`SHGetKnownFolderPath`) - not an environment variable a
//! test process can override. Setting `HOME` before calling
//! `nexus_core::Paths::resolve()` on Windows has no effect on where it looks
//! for `config.toml`. There's also no test-only override hook anywhere in
//! this codebase for `config_dir` specifically (`NEXUS_CACHE_DIR` only
//! overrides `data_dir`), so there is currently no way to inject a
//! controlled `allowed_roots` into these functions on Windows at all - a
//! more fundamental blocker than "symlinks need elevation."
//! known-folder API (`SHGetKnownFolderPath`) and ignores those env vars
//! entirely - so no test in this file could run on Windows at all, not just
//! the symlink-specific ones.
//!
//! Symlink creation itself, separately, turns out **not** to be the
//! blocker it looked like: probed directly against this repo's own
//! `test-windows` CI job (`windows-latest` GitHub Actions runner) with a
//! throwaway `std::os::windows::fs::symlink_file` call, which succeeded
//! without any elevation or Developer Mode step. So if/when a
//! cross-platform config-injection mechanism exists (tracked as issue #85,
//! not attempted here - it's a real harness change, not a test tweak), the
//! symlink-specific cases below should extend to Windows too, not stay
//! Unix-only.
#![cfg(unix)]
//! `NEXUS_CONFIG_DIR` (a plain env var read in `Paths::resolve()`, not an OS
//! API) sidesteps that entirely: it works identically on every platform, so
//! `setup_fake_home` now sets it directly instead of redirecting
//! `$HOME`/`$XDG_CONFIG_HOME`, and every test below except the
//! symlink-creating ones runs unconditionally, including on Windows.
//!
//! Symlink creation is the one remaining Unix-only piece, and only because
//! these specific tests use `std::os::unix::fs::symlink`, not for any
//! config-injection reason - probed directly against this repo's own
//! `test-windows` CI job (`windows-latest` GitHub Actions runner), Windows
//! itself can create symlinks without elevation or a Developer Mode step,
//! so a `std::os::windows::fs::symlink_file`/`symlink_dir`-based Windows
//! equivalent of those cases is possible; it's just not implemented here.
//! Those tests stay behind `#[cfg(unix)]` individually rather than gating
//! the whole file.

use std::path::{Path, PathBuf};
use std::sync::Mutex;
Expand All @@ -66,60 +63,44 @@ fn scratch_dir(label: &str) -> PathBuf {
))
}

/// Sets up a fake `$HOME` with `config.toml` configured with
/// `allowed_roots = [allowed_root]` at whatever path
/// `nexus_core::Paths::resolve()` actually resolves for that `$HOME`, and
/// returns the fake home dir plus a guard that restores the previous
/// `$HOME`/`$XDG_CONFIG_HOME` on drop.
/// Sets up a fake config dir (via the `NEXUS_CONFIG_DIR` override, issue
/// #85) with `config.toml` configured with `allowed_roots = [allowed_root]`,
/// and returns a guard that restores the previous `NEXUS_CONFIG_DIR` on
/// drop.
///
/// This must go through `Paths::resolve()` itself rather than hardcoding
/// `$HOME/.config/nexuscontext` - `directories::ProjectDirs` (what
/// `Paths::resolve()` uses) honors `$XDG_CONFIG_HOME` over `$HOME` on
/// Linux when set (as some CI runners do), and uses an entirely different
/// layout on macOS (`~/Library/Application Support`, not `~/.config`).
/// Hardcoding the Linux path silently no-ops the whole test on both: the
/// fake config is never found, `allowed_roots` falls back to "unrestricted"
/// (its documented behavior for an absent/empty config), and every
/// "must be rejected" assertion below fails as "the call succeeded".
/// Works identically on every platform: `NEXUS_CONFIG_DIR` is a plain env
/// var read in `Paths::resolve()`, not routed through the OS-specific
/// `directories` crate the way `$HOME`/`$XDG_CONFIG_HOME` redirection used
/// to be.
struct FakeHome {
prev_home: Option<std::ffi::OsString>,
prev_xdg_config_home: Option<std::ffi::OsString>,
prev_config_dir: Option<std::ffi::OsString>,
_dir: PathBuf,
}

impl Drop for FakeHome {
fn drop(&mut self) {
match &self.prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match &self.prev_xdg_config_home {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
match &self.prev_config_dir {
Some(v) => std::env::set_var("NEXUS_CONFIG_DIR", v),
None => std::env::remove_var("NEXUS_CONFIG_DIR"),
}
}
}

fn setup_fake_home(label: &str, allowed_root: &Path) -> FakeHome {
let home = scratch_dir(&format!("home-{label}"));
std::fs::create_dir_all(&home).unwrap();
let config_dir = scratch_dir(&format!("home-{label}"));
std::fs::create_dir_all(&config_dir).unwrap();

let prev_home = std::env::var_os("HOME");
let prev_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("HOME", &home);
// A stray XDG_CONFIG_HOME from the outer environment would otherwise
// keep pointing at the real config dir even after $HOME is redirected.
std::env::remove_var("XDG_CONFIG_HOME");
let prev_config_dir = std::env::var_os("NEXUS_CONFIG_DIR");
std::env::set_var("NEXUS_CONFIG_DIR", &config_dir);

let config_file = nexus_core::Paths::resolve().config_file();
std::fs::create_dir_all(config_file.parent().unwrap()).unwrap();
let config_toml = format!("allowed_roots = [{:?}]\n", allowed_root.to_string_lossy());
std::fs::write(&config_file, config_toml).unwrap();

FakeHome {
prev_home,
prev_xdg_config_home,
_dir: home,
prev_config_dir,
_dir: config_dir,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ attempted inline here. Once that exists, the symlink-creation finding above
means the symlink-specific cases can extend to Windows immediately, with no
further platform investigation needed.

## Update (2026-08-27): `NEXUS_CONFIG_DIR` harness built, Windows CI now runs the suite (#85, #83)

`nexus_core::Paths::resolve()` gained a `NEXUS_CONFIG_DIR` env override for
`config_dir`, mirroring the existing `NEXUS_CACHE_DIR` override for
`data_dir` - a plain env var read, no `directories`-crate/OS-API
involvement, so it behaves identically on every platform. `path_security.
rs`'s `setup_fake_home` now sets this directly instead of redirecting
`$HOME`/`$XDG_CONFIG_HOME`, and the file's blanket `#![cfg(unix)]` gate is
removed - only the three symlink-creating tests keep an individual
`#[cfg(unix)]`. Verified via real `test-windows` CI run, not just local
compilation (see PR closing this issue and #83 for the job log evidence).

## Related

[[Security-Model]] · [issue #61](https://github.com/devNalyx/NexusContext/issues/61) ·
Expand Down
7 changes: 7 additions & 0 deletions docs/NexusContext-Wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ request_timeout_secs = 10

- `NEXUS_CACHE_DIR` — overrides the data dir (`~/.local/share/nexuscontext`
by default).
- `NEXUS_CONFIG_DIR` — overrides the config dir (`~/.config/nexuscontext` on
Linux, `~/Library/Application Support/nexuscontext` on macOS, the roaming
`AppData` known folder on Windows, by default) — where `config.toml` is
read from. A plain env var read, not routed through the OS-specific
`directories` crate, so it works identically cross-platform. Primarily for
tests that need a controlled `allowed_roots` without depending on
home-directory resolution (see `crates/nexus-index/tests/path_security.rs`).
- `NEXUS_LOG_LEVEL` — `trace`/`debug`/`info`/`warn`/`error`.
- `NEXUS_LOG_FORMAT=json` — structured, machine-parseable logs; plain text
is the default. Works for both `serve` and `mcp` modes.
Expand Down
24 changes: 24 additions & 0 deletions docs/NexusContext-Wiki/Security-Model.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,30 @@ The general CI matrix (`test-windows`) does exercise the rest of the
workspace's test suite on Windows already - this gap is specific to this
one security-focused adversarial file, not a general Windows-support gap.

## Update (2026-08-27): `NEXUS_CONFIG_DIR` closes the config-injection gap (#85, #83)

Issue #85 built the harness the previous update called for: `nexus_core::
Paths::resolve()` now honors a `NEXUS_CONFIG_DIR` env override for
`config_dir`, the same way it already honored `NEXUS_CACHE_DIR` for
`data_dir`. It's a plain env var read, not routed through the OS-specific
`directories` crate, so it works identically on every platform.

`path_security.rs`'s `setup_fake_home`/`FakeHome` now sets
`NEXUS_CONFIG_DIR` directly instead of redirecting `$HOME`/
`$XDG_CONFIG_HOME`, and the blanket `#![cfg(unix)]` gate at the top of the
file is gone. Every non-symlink test (outside-root rejection,
`..`-traversal, the confused-deputy `$HOME/.ssh/id_rsa` case, and their
per-function variants across all seven `repo_path`-accepting tools) now
runs unconditionally, including on Windows. Only the three tests that
create real symlinks (`get_file_context_rejects_symlink_escaping_
allowed_root`, `get_file_context_rejects_repo_path_itself_a_symlink_
escaping_allowed_root`, `get_file_context_accepts_symlink_pointing_within_
the_same_allowed_root`) keep an individual `#[cfg(unix)]`, since Windows
symlink creation (confirmed CI-safe in the earlier update) has no
`std::os::windows::fs::symlink_file`/`symlink_dir` equivalent implemented
for them yet - a smaller, well-scoped remainder rather than the
whole-file blocker this section originally described.

## Trust boundary, stated plainly

The MCP tools trust the calling agent, not arbitrary network input — there
Expand Down
Loading