Skip to content
Open
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
174 changes: 174 additions & 0 deletions crates/openshell-cli/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ use openshell_core::proto::{
PlatformEvent, SandboxPhase, SandboxPolicy, SettingValue, setting_value,
};
use openshell_core::settings::{self, SettingValueKind};
use openshell_providers::builtin_profiles;
use owo_colors::OwoColorize;
use std::collections::HashMap;
use std::io::IsTerminal;
use std::process::Command;
use std::time::{Duration, Instant};

const DOCS_PROVIDERS_URL: &str = "https://docs.nvidia.com/openshell/latest/sandboxes/providers-v2";

// ---------------------------------------------------------------------------
// View types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -743,6 +746,92 @@ pub fn parse_duration_to_ms(s: &str) -> Result<i64> {
// Parsing utilities
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileSuggestion {
pub provider_type: String,
pub credential: String,
}

fn credential_env_matches(env: &HashMap<String, String>) -> Vec<(String, Vec<ProfileSuggestion>)> {
const SUFFIXES: [&str; 7_usize] = [
Comment thread
letv1nnn marked this conversation as resolved.
"TOKEN",
"SECRET",
"PASSWORD",
"CREDENTIAL",
"ACCESS_KEY",
"SECRET_KEY",
"API_KEY",
];
let looks_like_credential = |key: &str| -> bool {
let upper = key.to_ascii_uppercase();
SUFFIXES.iter().any(|s| upper.contains(*s))
};

// scan builtin_profiles()
let profile_suggestions = |key: &str| -> Vec<ProfileSuggestion> {
let mut suggestions = Vec::new();
for profile in builtin_profiles() {
for cred in &profile.credentials {
if cred.env_vars.iter().any(|v| v.eq_ignore_ascii_case(key)) {
suggestions.push(ProfileSuggestion {
provider_type: profile.id.clone(),
credential: cred.name.clone(),
});
}
}
}
suggestions
};

let mut matches = Vec::new();

for key in env.keys() {
let sug = profile_suggestions(key);
if !sug.is_empty() || looks_like_credential(key) {
matches.push((key.clone(), sug));
}
}

matches.sort_by(|a, b| a.0.cmp(&b.0));
matches
}

#[allow(clippy::implicit_hasher)]
pub fn warn_credential_env_vars(env: &HashMap<String, String>, suppress: bool) {
if suppress {
return;
}

let matches = credential_env_matches(env);
if matches.is_empty() {
return;
}

for (key, suggestions) in &matches {
eprintln!(
"{} {key} looks like a credential passed as a plain environment variable.",
"⚠".yellow()
);
eprintln!(" The agent inside the sandbox can read this value directly.");
eprintln!();

if suggestions.is_empty() {
eprintln!(" To hide it from the agent, use a provider instead of --env.");
} else {
eprintln!(" To hide it from the agent, use a provider instead:");
for s in suggestions {
eprintln!(
" openshell provider create --name my-{ty} --type {ty} --credential {key}",
ty = s.provider_type
);
}
eprintln!(" openshell sandbox create --provider my-<name> ...");
}
eprintln!(" See: {DOCS_PROVIDERS_URL}");
eprintln!();
}
}

pub fn parse_key_value_pairs(items: &[String], flag: &str) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();

Expand Down Expand Up @@ -975,4 +1064,89 @@ mod tests {
let err = parse_duration_to_ms("\u{20ac}").expect_err("missing number should error");
assert!(err.to_string().contains("invalid duration"));
}

// helper for building input
fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}

#[test]
fn suffix_match_no_profile() {
let env = env(&[("FOO_TOKEN", "x")]);
let prof = credential_env_matches(&env);
assert_eq!(prof.len(), 1_usize);
assert_eq!(&prof[0].0, "FOO_TOKEN");
assert!(prof[0].1.is_empty());
}

#[test]
fn exact_profile_match() {
let env = env(&[("GITHUB_TOKEN", "x")]);

let prof = credential_env_matches(&env);
assert_eq!(prof.len(), 1_usize);
assert_eq!(prof[0].0, "GITHUB_TOKEN");

let sug = &prof[0].1;
assert_eq!(sug.len(), 2_usize);

assert_eq!(sug[0].provider_type, "copilot");
assert_eq!(sug[0].credential, "api_token");

assert_eq!(sug[1].provider_type, "github");
assert_eq!(sug[1].credential, "api_token");
}

#[test]
fn case_insensitive() {
let env = env(&[("gh_token", "x")]);

let prof = credential_env_matches(&env);
assert_eq!(prof.len(), 1_usize);
assert_eq!(prof[0].0, "gh_token");

let sug = &prof[0].1;
assert_eq!(sug.len(), 2_usize);

assert_eq!(sug[0].provider_type, "copilot");
assert_eq!(sug[0].credential, "api_token");

assert_eq!(sug[1].provider_type, "github");
assert_eq!(sug[1].credential, "api_token");
}

#[test]
fn non_credential_skipped() {
let env = env(&[("PATH", "x"), ("HOME", "y")]);

let prof = credential_env_matches(&env);
assert!(prof.is_empty());
}

#[test]
fn no_value_leak() {
let env = env(&[("APP_SECRET", "secretVALUE42")]);

let prof = credential_env_matches(&env);
assert_eq!(prof.len(), 1_usize);

let dumped = format!("{prof:?}");
assert!(!dumped.contains("secretVALUE42"), "value leaked: {dumped}");
}

#[test]
fn deterministic_order() {
let env = env(&[
("ZED_TOKEN", "a"),
("ABC_SECRET", "b"),
("MID_PASSWORD", "c"),
]);

let prof = credential_env_matches(&env);
let keys: Vec<&str> = prof.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, ["ABC_SECRET", "MID_PASSWORD", "ZED_TOKEN"]);
}
}
6 changes: 6 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,10 @@ enum SandboxCommands {
#[arg(long = "env", value_name = "KEY=VALUE")]
envs: Vec<String>,

/// Suppress warnings when --env values look like credentials.
#[arg(long = "no-credential-warnings")]
no_credential_warnings: bool,

/// Approval mode for agent-authored policy proposals.
///
/// `manual` (default): every proposal lands in the draft inbox for
Expand Down Expand Up @@ -2934,6 +2938,7 @@ async fn main() -> Result<()> {
no_auto_providers,
labels,
envs,
no_credential_warnings,
approval_mode,
output,
command,
Expand Down Expand Up @@ -2971,6 +2976,7 @@ async fn main() -> Result<()> {

// Parse --env flags into a HashMap<String, String>.
let env_map = run::parse_env_pairs(&envs)?;
run::warn_credential_env_vars(&env_map, no_credential_warnings);

// Parse --upload specs into [(local_path, sandbox_path, git_ignore)].
let upload_specs: Vec<(String, Option<String>, bool)> = upload
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
pub use crate::commands::common::{
PolicyGetView, parse_credential_expiry_cli_value, parse_env_pairs, parse_key_value_pairs,
parse_secret_material_env_pairs,
parse_secret_material_env_pairs, warn_credential_env_vars,
};
use crate::commands::common::{
ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete,
Expand Down
2 changes: 2 additions & 0 deletions docs/sandboxes/manage-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent
Variables set with `--env` are available to all processes in the sandbox, including interactive shells and exec commands.
When an `--env` key looks like a credential — a known provider variable or a name ending in `_TOKEN`, `_SECRET`, `_API_KEY`, and similar — `sandbox create` prints a non-blocking warning. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [provider](/sandboxes/providers-v2) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed.
You can also set per-command environment variables with `sandbox exec`:
```shell
Expand Down
Loading