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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,16 @@ beyond a development machine, review the
`ASTRA_RUNTIME_ROOT_SECRET`, and `MEMORIA_MASTER_KEY`. Never ship the
template values.
- Keep `.env`, `.env.production`, and `.models.yaml` out of version control.
The CLI loads `.env` from the working directory but refuses one that sets
`ASTRA_LOCAL_STATE_ROOT` or `MOI_AUTH_DIR`; set those in the shell.
- Bind development services to loopback. `ASTRA_BIND_ADDRESS` defaults to
`127.0.0.1` for a reason.
- Leave Edge/CLI `[web_fetch] fake_ip` at `block` unless a fake-IP VPN
blocks web access. `allow_doh_screened` relaxes the private-network SSRF
boundary for fake-IP answers: the VPN picks the real destination, and the
DoH screen cannot detect a host that answers the VPN with a private address;
see the
[configuration reference](docs/reference/configuration.md#local-web_fetch-behind-fake-ip-dns).
- Remember that Runner placement controls where execution happens, not where
data goes. Model endpoints, context disclosure, and tool-result handling
remain explicit policy choices.
6 changes: 3 additions & 3 deletions crates/astra-cli/src/edge_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,7 @@ impl ToolExecutor {
cancel_token: None,
},
)
.with_local_network(),
.with_local_network(astra_tools::web_fetch::configured_fake_ip_policy()),
plan_mode_authoring_cache: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
ask_user_request_tx: std::sync::Mutex::new(None),
plan_review_request_tx: std::sync::Mutex::new(None),
Expand Down Expand Up @@ -5201,7 +5201,7 @@ impl ToolExecutor {
let result = astra_tools::web_fetch::fetch_with_cache_scope(
args,
&cache_scope,
astra_tools::web_fetch::FetchTransport::LocalEnvironment,
self.default_executor.fetch_network(),
)
.await;
*source_is_error = Some(result.is_error);
Expand Down Expand Up @@ -5491,7 +5491,7 @@ impl ToolExecutor {
let result = astra_tools::web_search::perform_web_search(
args,
&cache_scope,
astra_tools::web_fetch::FetchTransport::LocalEnvironment,
self.default_executor.fetch_network(),
)
.await;
*source_is_error = Some(result.is_error);
Expand Down
52 changes: 34 additions & 18 deletions crates/astra-cli/src/entrypoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,42 @@ use cli::project_instructions::{

// ════════════════════════════════════════════════════════════════ main ════

fn validate_native_directory_source(
/// Variables that relocate trusted user state (credentials, user-level
/// configuration, permissions and workspace trust). A workspace `.env` must
/// not supply them; only the launching shell may. `HOME` is the fallback root
/// for all of these when the others are unset, so a sanitized environment
/// without it must not let the workspace choose it.
const SHELL_ONLY_ENV: [&str; 3] = [
"MOI_AUTH_DIR",
astra_core::local_state::ASTRA_LOCAL_STATE_ROOT_ENV,
"HOME",
];

fn validate_shell_only_source(
name: &str,
before: Option<std::ffi::OsString>,
after: Option<std::ffi::OsString>,
) -> Result<(), &'static str> {
) -> Result<(), String> {
if before != after {
return Err(
"MOI_AUTH_DIR cannot be supplied by a workspace .env; set it explicitly in your shell",
);
return Err(format!(
"{name} cannot be supplied by a workspace .env; set it explicitly in your shell"
));
}
Ok(())
}

#[test]
fn workspace_dotenv_cannot_select_native_auth_directory() {
assert!(validate_native_directory_source(None, Some("/untrusted".into())).is_err());
assert!(validate_native_directory_source(None, Some("".into())).is_err());
assert!(validate_native_directory_source(None, None).is_ok());
assert!(
validate_native_directory_source(Some("/trusted".into()), Some("/trusted".into())).is_ok()
);
fn workspace_dotenv_cannot_select_trusted_state_roots() {
for name in SHELL_ONLY_ENV {
let rejected = validate_shell_only_source(name, None, Some("/untrusted".into()));
assert!(rejected.unwrap_err().contains(name));
assert!(validate_shell_only_source(name, None, Some("".into())).is_err());
assert!(validate_shell_only_source(name, None, None).is_ok());
assert!(
validate_shell_only_source(name, Some("/trusted".into()), Some("/trusted".into()))
.is_ok()
);
}
}

pub fn run() -> i32 {
Expand Down Expand Up @@ -155,13 +171,13 @@ async fn run_async() -> i32 {
// Chat/login can launch credential-consuming children too. Inspect the
// actual loaded environment, not a second parse of a mutable .env file.
if !explicit_env_config {
let native_directory = std::env::var_os("MOI_AUTH_DIR");
let shell_values = SHELL_ONLY_ENV.map(std::env::var_os);
dotenvy::dotenv().ok();
if let Err(error) =
validate_native_directory_source(native_directory, std::env::var_os("MOI_AUTH_DIR"))
{
eprintln!("Error: {error}");
return 2;
for (name, before) in SHELL_ONLY_ENV.into_iter().zip(shell_values) {
if let Err(error) = validate_shell_only_source(name, before, std::env::var_os(name)) {
eprintln!("Error: {error}");
return 2;
}
}
}
cli::diagnostic_log::init_cli_observability(&cli);
Expand Down
170 changes: 165 additions & 5 deletions crates/astra-cli/src/tui/config_edit_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use crate::cli::session::{session_startup, session_state::SessionState};
use crate::tui::bottom_pane::view::ConfigEditDisposition;
use astra_config::config_versions::{ConfigVersionStore, LocalFileStore, PutMetadata, VersionId};
use astra_config::runtime_config::{RuntimeConfig, user_runtime_config_path};
use astra_config::runtime_config::{RuntimeConfig, USER_ONLY_SECTIONS, user_runtime_config_path};
use std::path::PathBuf;

/// Result of resolving a config-editor completion.
Expand Down Expand Up @@ -149,9 +149,52 @@ fn save_and_report(
std::fs::create_dir_all(parent)
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
}
let pretty = toml::to_string_pretty(&parsed)
if scope == "project"
&& let Some(user_path) = user_runtime_config_path()
&& same_config_target(&path, &user_path)
{
return Err("Project and user configuration refer to the same file; save as user configuration instead".into());
}
let complete = toml::to_string_pretty(&parsed)
.map_err(|e| format!("Could not serialize config back to TOML: {e}"))?;
std::fs::write(&path, &pretty).map_err(|e| format!("Write failed {}: {e}", path.display()))?;
let pretty = if scope == "project" {
let mut value: toml::Value = toml::from_str(&complete)
.map_err(|e| format!("Could not prepare project configuration: {e}"))?;
if let Some(table) = value.as_table_mut() {
for section in USER_ONLY_SECTIONS {
table.remove(*section);
}
}
toml::to_string_pretty(&value)
.map_err(|e| format!("Could not serialize project configuration: {e}"))?
} else {
complete.clone()
};
if scope == "user" {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&path)
.map_err(|e| format!("Write failed {}: {e}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("Could not protect {}: {e}", path.display()))?;
}
file.set_len(0)
.and_then(|_| file.write_all(pretty.as_bytes()))
.map_err(|e| format!("Write failed {}: {e}", path.display()))?;
} else {
std::fs::write(&path, &pretty)
.map_err(|e| format!("Write failed {}: {e}", path.display()))?;
}

// Content-addressed put: computes the new id and dedups on repeat
// saves. Best-effort — if the store is unavailable (no HOME, disk
Expand All @@ -167,12 +210,12 @@ fn save_and_report(
.put(&parsed, meta)
.map(|id| id.as_str().to_string())
.unwrap_or_else(|_| {
VersionId::from_toml_bytes(pretty.as_bytes())
VersionId::from_toml_bytes(complete.as_bytes())
.as_str()
.to_string()
})
}
None => VersionId::from_toml_bytes(pretty.as_bytes())
None => VersionId::from_toml_bytes(complete.as_bytes())
.as_str()
.to_string(),
};
Expand All @@ -186,6 +229,29 @@ fn save_and_report(
})
}

fn same_config_target(left: &std::path::Path, right: &std::path::Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(left), Ok(right)) = (std::fs::metadata(left), std::fs::metadata(right))
&& left.dev() == right.dev()
&& left.ino() == right.ino()
{
return true;
}
}
fn resolve(path: &std::path::Path) -> Option<PathBuf> {
std::fs::canonicalize(path).ok().or_else(|| {
Some(
std::fs::canonicalize(path.parent()?)
.ok()?
.join(path.file_name()?),
)
})
}
matches!((resolve(left), resolve(right)), (Some(left), Some(right)) if left == right)
}

fn scope_path(scope: &str) -> Result<PathBuf, String> {
match scope {
"user" => user_runtime_config_path()
Expand All @@ -203,6 +269,100 @@ mod tests {
use super::finalize;
use crate::tui::bottom_pane::view::ConfigEditDisposition;

#[test]
fn project_save_omits_user_only_policy_and_rejects_shared_target() {
const CHILD: &str = "ASTRA_TEST_PROJECT_CONFIG_SAVE";
if std::env::var_os(CHILD).is_some() {
let trusted_root = std::env::var_os("ASTRA_LOCAL_STATE_ROOT").unwrap();
let mut config = astra_config::RuntimeConfig::default();
config.memory.retrieval_top_k = 7;
config.web_fetch.doh_resolvers =
vec!["https://dns.example/private-token?key=secret".into()];
let body = toml::to_string_pretty(&config).unwrap();
finalize(ConfigEditDisposition::SaveProject, &body).unwrap();
let project = std::fs::read_to_string(".astra/config/runtime.toml").unwrap();
assert!(!project.contains("web_fetch"));
assert!(!project.contains("private-token"));
assert_eq!(
toml::from_str::<astra_config::RuntimeConfig>(&project)
.unwrap()
.memory
.retrieval_top_k,
7
);
unsafe {
std::env::set_var("ASTRA_LOCAL_STATE_ROOT", ".astra");
}
assert!(
finalize(ConfigEditDisposition::SaveProject, &body)
.unwrap_err()
.contains("same file")
);
assert_eq!(
std::fs::read_to_string(".astra/config/runtime.toml").unwrap(),
project
);
#[cfg(unix)]
{
let user_file = std::path::PathBuf::from(&trusted_root).join("config/runtime.toml");
std::fs::create_dir_all(user_file.parent().unwrap()).unwrap();
std::fs::write(&user_file, "user policy").unwrap();
std::fs::remove_file(".astra/config/runtime.toml").unwrap();
std::fs::hard_link(&user_file, ".astra/config/runtime.toml").unwrap();
unsafe {
std::env::set_var("ASTRA_LOCAL_STATE_ROOT", trusted_root);
}
assert!(
finalize(ConfigEditDisposition::SaveProject, &body)
.unwrap_err()
.contains("same file")
);
assert_eq!(std::fs::read_to_string(user_file).unwrap(), "user policy");
}
return;
}
let workspace = tempfile::tempdir().unwrap();
let state = tempfile::tempdir().unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "tui::config_edit_router::tests::project_save_omits_user_only_policy_and_rejects_shared_target", "--nocapture"])
.env(CHILD, "1")
.env("ASTRA_LOCAL_STATE_ROOT", state.path())
.current_dir(workspace.path())
.output().unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}

#[cfg(unix)]
#[test]
#[serial_test::serial]
fn user_save_protects_new_and_existing_configuration_files() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let _env = crate::test_utils::ProcessEnvGuard::remove("ASTRA_LOCAL_STATE_ROOT");
unsafe {
std::env::set_var("ASTRA_LOCAL_STATE_ROOT", root.path());
}
let mut config = astra_config::RuntimeConfig::default();
config.web_fetch.doh_resolvers = vec!["https://dns.example/private-token".into()];
let body = toml::to_string_pretty(&config).unwrap();
finalize(ConfigEditDisposition::SaveUser, &body).unwrap();
let file = root.path().join("config/runtime.toml");
assert_eq!(
std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
0o600
);
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
finalize(ConfigEditDisposition::SaveUser, &body).unwrap();
assert_eq!(
std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
0o600
);
}

#[test]
fn discard_and_cancel_produce_friendly_messages() {
let a = finalize(ConfigEditDisposition::Discard, "").unwrap();
Expand Down
Loading
Loading