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:
6 files changed
+269
-32
No files matched your search
+132
-1
@@ -6,7 +6,9 @@
|
||||
//! --list-secrets depend on host state and are covered by
|
||||
//! scripts/usage-probe-inspection.sh instead. Agent runs, by contrast,
|
||||
//! must still bootstrap builtins even when combined with --info — see
|
||||
//! agent_info_on_empty_config_dir_bootstraps_builtins.
|
||||
//! agent_info_on_empty_config_dir_bootstraps_builtins. Flags that write,
|
||||
//! like --sync-models, are NOT inspection flags and must stay on the
|
||||
//! bootstrap path — see sync_models_stays_on_bootstrap_path.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@@ -122,3 +124,132 @@ fn agent_info_on_empty_config_dir_bootstraps_builtins() {
|
||||
"--agent adversary --info: expected builtin tool fs_cat.sh in the readout\nstdout: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
// Sandbox-mode vault contract: `--list-secrets` is an inspection readout and
|
||||
// must exit 0 with an informational message (and zero writes) when the vault
|
||||
// is unreachable inside a sandbox, while mutating vault flags stay strict.
|
||||
#[test]
|
||||
fn sandbox_list_secrets_prints_informational_message_and_writes_nothing() {
|
||||
let tmp_dir = fresh_config_dir("sandbox-list-secrets");
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
|
||||
.arg("--list-secrets")
|
||||
.env("COYOTE_CONFIG_DIR", &tmp_dir)
|
||||
.env("IS_SANDBOX", "1")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let leftover: Vec<_> = fs::read_dir(&tmp_dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"--list-secrets in sandbox mode: expected exit 0, got {:?}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
combined.contains("The vault is unavailable in sandbox mode"),
|
||||
"--list-secrets in sandbox mode: expected the informational readout, got: {combined}"
|
||||
);
|
||||
assert!(
|
||||
leftover.is_empty(),
|
||||
"--list-secrets in sandbox mode: expected zero writes into the config dir: {leftover:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Mutating vault flags are NOT inspection flags: in sandbox mode they must
|
||||
// fail hard with the vault-management error, never the soft informational
|
||||
// message. A minimal valid config is provided so the run gets past model
|
||||
// resolution and reaches the vault gate.
|
||||
#[test]
|
||||
fn sandbox_mutating_vault_flag_stays_strict() {
|
||||
let tmp_dir = fresh_config_dir("sandbox-delete-secret");
|
||||
fs::write(
|
||||
tmp_dir.join("config.yaml"),
|
||||
"model: dryrun:dry-model\n\
|
||||
dry_run: true\n\
|
||||
clients:\n\
|
||||
\x20 - type: openai\n\
|
||||
\x20 name: dryrun\n\
|
||||
\x20 auth: none\n\
|
||||
\x20 api_key: unused\n\
|
||||
\x20 models:\n\
|
||||
\x20 - name: dry-model\n\
|
||||
\x20 max_input_tokens: 100000\n\
|
||||
\x20 supports_function_calling: true\n\
|
||||
save: false\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
|
||||
.args(["--delete-secret", "probe"])
|
||||
.env("COYOTE_CONFIG_DIR", &tmp_dir)
|
||||
.env("IS_SANDBOX", "1")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"--delete-secret in sandbox mode: expected a non-zero exit, got {:?}\nstdout: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
combined.contains("Vault management is disabled in sandbox mode"),
|
||||
"--delete-secret in sandbox mode: expected the strict vault error, got: {combined}"
|
||||
);
|
||||
}
|
||||
|
||||
// --sync-models downloads and writes models-override.yaml, so it is NOT an
|
||||
// inspection flag: on a pristine config dir it must take the bootstrap path
|
||||
// (builtins installed) and, off a terminal, fail on the missing config
|
||||
// instead of silently operating on defaults. The host path is the one under
|
||||
// test: a leaked IS_SANDBOX (or provider env) would reroute the missing
|
||||
// config to the sandbox wizard prompt instead of the fast failure.
|
||||
#[test]
|
||||
fn sync_models_stays_on_bootstrap_path() {
|
||||
let tmp_dir = fresh_config_dir("sync-models");
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_coyote"))
|
||||
.arg("--sync-models")
|
||||
.env("COYOTE_CONFIG_DIR", &tmp_dir)
|
||||
.env_remove("IS_SANDBOX")
|
||||
.env_remove("COYOTE_PROVIDER")
|
||||
.env_remove("COYOTE_PLATFORM")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let bootstrapped = fs::read_dir(&tmp_dir).unwrap().next().is_some();
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"--sync-models: expected the strict missing-config failure on an empty config dir, got {:?}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
bootstrapped,
|
||||
"--sync-models: expected the builtins bootstrap to populate the config dir"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user