fix(TASK-046): keep inspection leniency off mutating and writing flags

The look-don't-touch bypass was keyed on load_with_interpolation's
pre-existing bool, which mutating early paths (--authenticate, --auth-mcp,
mcp manage, vault flags) already passed as true meaning only 'skip vault
interpolation' — so they silently lost their first-run config creation.
Split the parameter: skip_interpolation keeps the historical meaning,
a distinct inspection flag alone gates the missing-config default/wizard
bypass.

inspection_only is now computed from an explicit read-only whitelist:
--sync-models is excluded (it downloads and writes models-override.yaml)
and returns to the bootstrap/strict path, keeping only its historical
interpolation-skip and vault leniency. mcp/vault inspection sub-conditions
are hoisted and shared so mutating flag combos never inherit the bypass.

Coverage: sandbox vault contract tests, a sync-models bootstrap-path pin,
a --sync-models scenario in usage-probe-inspection.sh, and a PTY wizard
regression probe (scripts/usage-probe-wizard.sh).
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 16:48:57 -06:00
1 parent 9da0b91673
commit d89d24aff4
6 files changed
+269 -32

No files matched your search

+6 -4
View File
@@ -50,13 +50,15 @@ pub(super) fn model_completer(current: &OsStr) -> Vec<CompletionCandidate> {
fn load_app_config_for_completion() -> anyhow::Result<AppConfig> {
let h = tokio::runtime::Handle::try_current().ok();
// Tab completion is a read-only inspection surface: never run the
// first-run wizard or write anything from a completer.
let cfg = match h {
Some(handle) => {
tokio::task::block_in_place(|| handle.block_on(Config::load_with_interpolation(true)))?
}
Some(handle) => tokio::task::block_in_place(|| {
handle.block_on(Config::load_with_interpolation(true, true))
})?,
None => {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(Config::load_with_interpolation(true))?
rt.block_on(Config::load_with_interpolation(true, true))?
}
};
AppConfig::from_config(cfg)
+10 -7
View File
@@ -729,14 +729,17 @@ pub fn maybe_spawn_models_refresh(app: &AppConfig) {
}
impl Config {
pub async fn load_with_interpolation(info_flag: bool) -> Result<Self> {
pub async fn load_with_interpolation(
skip_interpolation: bool,
inspection: bool,
) -> Result<Self> {
let config_path = paths::config_file();
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
if !config_path.exists() {
// Inspection-only flags are look-don't-touch: no first-run
// config creation, nothing written.
if info_flag {
if inspection {
return Ok(Self::default());
}
create_config_file(&config_path).await?;
@@ -756,7 +759,7 @@ impl Config {
// Same look-don't-touch rule: never run the interactive
// first-run wizard (or write a config) for a flag that
// only inspects state.
if info_flag {
if inspection {
return Ok(Self::default());
}
if *IS_STDOUT_TERMINAL {
@@ -769,7 +772,7 @@ impl Config {
Self::load_from_file(&config_path)?
};
if info_flag {
if skip_interpolation {
return Ok(config);
}
@@ -1627,7 +1630,7 @@ hooks:
std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1");
}
let result = Config::load_with_interpolation(false).await;
let result = Config::load_with_interpolation(false, false).await;
let (_, raw) = Config::load_from_file(&config_path).unwrap();
unsafe {
@@ -1666,7 +1669,7 @@ hooks:
let _provider = crate::testing::EnvVarGuard::unset(get_env_name("provider"));
let _platform = crate::testing::EnvVarGuard::unset(get_env_name("platform"));
let result = Config::load_with_interpolation(true).await;
let result = Config::load_with_interpolation(true, true).await;
let leftover: Vec<_> = std::fs::read_dir(&tmp_dir)
.unwrap()
@@ -1694,7 +1697,7 @@ hooks:
crate::testing::EnvVarGuard::set(get_env_name("config_file"), &config_path);
let _sandbox = crate::testing::EnvVarGuard::set(crate::sandbox::SANDBOX_ENV_FLAG, "1");
let result = Config::load_with_interpolation(true).await;
let result = Config::load_with_interpolation(true, true).await;
let created = config_path.exists();
let _ = std::fs::remove_dir_all(&tmp_dir);
+30 -20
View File
@@ -113,6 +113,19 @@ async fn main() -> Result<()> {
|| cli.list_macros
|| cli.list_skills
|| cli.list_sessions;
// Read-only whitelist: --sync-models is excluded because it downloads
// and writes models-override.yaml; combined with a readout flag the run
// still writes (run() handles --sync-models first), so it disqualifies
// the whole invocation.
let readout_flag = (cli.info
|| cli.list_models
|| cli.list_roles
|| cli.list_agents
|| cli.list_rags
|| cli.list_macros
|| cli.list_skills
|| cli.list_sessions)
&& !cli.sync_models;
let vault_flags = cli.add_secret.is_some()
|| cli.get_secret.is_some()
|| cli.update_secret.is_some()
@@ -144,9 +157,15 @@ async fn main() -> Result<()> {
// bootstrap so a pristine config dir stays empty. On such a dir the
// list flags list nothing because nothing is installed, by design.
// Agent/role runs still bootstrap: they load builtin tool definitions.
let inspection_only = (info_flag || cli.list_secrets || cli.mcp_list)
&& cli.agent.is_none()
&& cli.role.is_none();
let mcp_inspect =
cli.mcp_list && cli.mcp_get.is_none() && cli.mcp_remove.is_none() && cli.mcp_add.is_none();
let vault_inspect = cli.list_secrets
&& cli.add_secret.is_none()
&& cli.get_secret.is_none()
&& cli.update_secret.is_none()
&& cli.delete_secret.is_none();
let inspection_only =
(readout_flag || vault_inspect || mcp_inspect) && cli.agent.is_none() && cli.role.is_none();
if !inspection_only {
install_builtins()?;
}
@@ -173,7 +192,7 @@ async fn main() -> Result<()> {
}
if let Some(client_arg) = &cli.authenticate {
let cfg = Config::load_with_interpolation(true).await?;
let cfg = Config::load_with_interpolation(true, false).await?;
let app_config = AppConfig::from_config(cfg)?;
let (client_name, provider) =
resolve_oauth_client(client_arg.as_deref(), &app_config.clients)?;
@@ -182,7 +201,7 @@ async fn main() -> Result<()> {
}
if let Some(server_name) = &cli.auth_mcp {
let cfg = Config::load_with_interpolation(true).await?;
let cfg = Config::load_with_interpolation(true, false).await?;
let app_config = AppConfig::from_config(cfg)?;
let vault = Vault::init(&app_config)?;
let mcp_path = paths::mcp_config_file();
@@ -234,12 +253,8 @@ async fn main() -> Result<()> {
let mcp_action =
cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some();
if mcp_action {
let cfg = Config::load_with_interpolation(true).await?;
let app_config = if cli.mcp_list
&& cli.mcp_get.is_none()
&& cli.mcp_remove.is_none()
&& cli.mcp_add.is_none()
{
let cfg = Config::load_with_interpolation(true, mcp_inspect).await?;
let app_config = if mcp_inspect {
AppConfig::from_config_lenient(cfg)?
} else {
AppConfig::from_config(cfg)?
@@ -252,13 +267,8 @@ async fn main() -> Result<()> {
}
if vault_flags {
let cfg = Config::load_with_interpolation(true).await?;
let app_config = if cli.list_secrets
&& cli.add_secret.is_none()
&& cli.get_secret.is_none()
&& cli.update_secret.is_none()
&& cli.delete_secret.is_none()
{
let cfg = Config::load_with_interpolation(true, vault_inspect).await?;
let app_config = if vault_inspect {
AppConfig::from_config_lenient(cfg)?
} else {
AppConfig::from_config(cfg)?
@@ -269,8 +279,8 @@ async fn main() -> Result<()> {
let abort_signal = create_abort_signal();
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
let cfg = Config::load_with_interpolation(info_flag).await?;
let mut app_config = if info_flag {
let cfg = Config::load_with_interpolation(info_flag, readout_flag).await?;
let mut app_config = if readout_flag {
AppConfig::from_config_lenient(cfg)?
} else {
AppConfig::from_config(cfg)?