diff --git a/README.md b/README.md index 05228317..413b8132 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,15 @@ wally shannon -- -u https://staging.example.com -r ./my-repo Needs Docker and Node 18+. See [Shannon](docs/EDITORS.md#shannon). +**Scan repos you are authorised to test:** + +```bash +wally deepsec -- init --repo ./my-repo +wally vvah -- scan --repo ./my-repo +``` + +DeepSec needs Node 18+ for `npx`. VVAH needs Python 3.11–3.14. See [Security tools](docs/EDITORS.md#security-tools). + **Get help:** ```bash diff --git a/docs/EDITORS.md b/docs/EDITORS.md index 5b2e0d4e..a265d4e3 100644 --- a/docs/EDITORS.md +++ b/docs/EDITORS.md @@ -32,6 +32,8 @@ tool starts the way you already have it configured, and wally wires nothing. | `prime-agent` | a temporary extension loaded with `-e` that registers the provider for the run, with the key in `RUNANYWHERE_API_KEY`; nothing enters `~/.prime/agent` | | `command-code` | a `runanywhere-wally` provider merged into `~/.commandcode/providers.json` (it reads providers from nowhere else) and taken out when it exits: the original bytes go back if the file is unchanged, otherwise only that provider comes out. The file names `$WALLY_COMMAND_CODE_API_KEY` rather than holding the key. A local model, or a person with no Command Code sign-in, runs with `CMD_LOCAL_ONLY=1` (no Command Code gateway models, `/share`, `/usage` or server-side web tools); with no sign-in a placeholder `COMMAND_CODE_API_KEY` gets past Command Code's login screen. `--skip-onboarding --no-auto-update` are passed, `-t` is left to you. One run at a time (a lock in `~/.commandcode/`). Its own subcommands (`-- status`, `-- login`, …) run untouched; `-- acp` is wired, and there the editor picks the model | | `shannon` | not a coding tool: it runs [Shannon](#shannon) on a model from your account. A `models.json` in your wally profile and `SHANNON_AI_MODEL` / `SHANNON_AI_API_KEY` in the child's environment only | +| `deepsec` | not a coding tool: it runs [DeepSec](#deepsec) on a hosted model from your account. Pi custom routing flags are injected for `init`, `setup`, `process` and `revalidate`; the API key is in `WALLY_DEEPSEC_API_KEY` for the child only | +| `vvah` | not a coding tool: it runs [VVAH](#vvah) on a hosted model from your account. A `config.local.yaml` names the model per role; `OPENAI_API_KEY` and `OPENAI_BASE_URL` are set for the child only | Two flags go with `-m`. `--serve` holds the endpoint open and prints it instead of launching anything, which is how a tool nobody has taught wally about gets @@ -404,3 +406,91 @@ available to your account. Some models need approval before an account can use them. If one is not on your account's list, wally says so; request access in the console, then run again. + +## Security tools + +`wally deepsec` and `wally vvah` point open-source repo scanners at models on +your account. They are hosted-only: there is no local-model path. **Only scan +repos and systems you own or are explicitly authorised to test.** + +Both need a signed-in account (`wally account login`), or a Cloud key from the +console. The key is never passed on the command line or written into config +files wally creates. For a run longer than a sign-in lasts, export a Cloud key +as `WALLY_CLOUD_KEY` before you start; wally reads it from the environment +only. + +## DeepSec + +[DeepSec](https://www.npmjs.com/package/deepsec) is an npm CLI wally does not +bundle. If `deepsec` is not on PATH, wally prints the `npx --yes deepsec@…` +command and asks before running it. Bare `wally deepsec` is DeepSec `init` in +the current directory: run it from a repo you own, not from `$HOME`. DeepSec +still links a Vercel project (Sandbox identity) even when models go through +your RunAnywhere account. + +What you need: + +- Node 18 or later (`npx`). +- A model on your account (`-m` or your default). + +```bash +wally account login +wally deepsec -- init --repo ./my-repo +``` + +With no DeepSec command after `--`, wally runs `init`. Other subcommands pass +through unless they are `init`, `setup`, `process` or `revalidate`, which get +the hosted route: + +```bash +wally deepsec -- status +wally deepsec -m glm-5.3-flash -- process +``` + +For those routed commands wally injects Pi-style flags ahead of yours: +`--agent pi`, `--model runanywhere/` (Pi requires `provider/model`), `--model-auth custom`, `--ai-provider runanywhere`, +`--ai-base-url `, `--ai-api-key-env WALLY_DEEPSEC_API_KEY`, and +`--ai-credential-header authorization:bearer`. It writes `models.json` under the +Wally profile and sets `PI_CODING_AGENT_DIR` plus `WALLY_DEEPSEC_API_KEY` for +DeepSec's process, and removes `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, +`AI_GATEWAY_API_KEY` and `WALLY_CLOUD_KEY` from the child so nothing else can +override your key. Passing your own `--model-auth`, `--ai-base-url` or +`--ai-api-key-env` on those commands is refused. + +## VVAH + +[VVAH](https://github.com/visa/visa-vulnerability-agentic-harness) is a Python +CLI wally does not bundle. If `vvaharness` is missing, wally prints the install +command, asks `Install it now with \`…\`? [y/N]`, and on yes creates a venv +under the Wally profile (`/vvah/`) and pip-installs the pinned tag. + +What you need: + +- Python 3.11–3.14 on PATH (`python3.12` is preferred; Homebrew's `python3` is + often 3.15, which VVAH cannot install on). Checked after you confirm install. +- A model on your account (`-m` or your default). + +```bash +wally account login +wally vvah +``` + +With no VVAH command after `--`, wally runs `scan --repo . --remediate`. Run +that from a project directory, not `$HOME` (the profile config lives under +`~/.config/wally`, and VVAH refuses a `--config` inside the scan target). Other +commands pass through: + +```bash +wally vvah -- scan --repo ./my-repo +wally vvah -m glm-5.3-flash -- report +``` + +Each run rewrites `/vvah/config.local.yaml` (owner-only, 0600). Every +active scanner role maps to your chosen model with `via: openai`; the file +never contains the API key. The child gets `OPENAI_API_KEY` and +`OPENAI_BASE_URL` only. VVAH's S10 stage can edit files on disk as part of a +scan; run it only on trees you are allowed to change. + +If you confirm install and `python3` is missing or older than 3.11, wally +prints `python 3.11 is not installed on this machine, Please install it first, Thank You` +and exits. The venv is left in place for the next run. diff --git a/skills/runanywhere/SKILL.md b/skills/runanywhere/SKILL.md index 91ef4681..78014131 100644 --- a/skills/runanywhere/SKILL.md +++ b/skills/runanywhere/SKILL.md @@ -72,6 +72,20 @@ sign-in lasts, the person exports a console Cloud key as `WALLY_CLOUD_KEY`. Setup details, and how to point any other OpenAI-compatible tool at the endpoint by hand, are in `docs/EDITORS.md`. +## Security tools + +`wally deepsec` and `wally vvah` run hosted security scanners on a model from the +person's account. Only suggest them for a repo or system they own or are +authorised to test. DeepSec needs Node 18+; VVAH needs Python 3.11–3.14. Wally sets +up routing and keys in the child environment, never on the command line. For a +run longer than a sign-in lasts, they export a console Cloud key as +`WALLY_CLOUD_KEY`. Details are in `docs/EDITORS.md`. + +```bash +wally deepsec -- init --repo "$REPO_PATH" +wally vvah -- scan --repo "$REPO_PATH" +``` + ## Running a model directly ```bash @@ -115,6 +129,7 @@ harness; those surfaces refuse it and point back here. - **"not signed in"** — `wally account login`. - **"not available on this account" or "not on your account's model list"** — that model needs approval. Do not log in again; request access in the console. +- **DeepSec or VVAH prerequisites missing** — install Node 18+ (DeepSec) or Python 3.11–3.14 (VVAH); see `docs/EDITORS.md`. - **"that key is not valid"** — the key was revoked or expired; `wally account login` again. - **opencode not installed** — `npm i -g opencode-ai`. - **a model is slow or unavailable** — `wally backends` shows which engines this diff --git a/src/app.rs b/src/app.rs index 6a355c32..a0f7c627 100644 --- a/src/app.rs +++ b/src/app.rs @@ -137,6 +137,10 @@ pub fn configure_app(app: &mut App) { "shannon", ], }, + Section { + group: "Security tools", + names: &["deepsec", "vvah"], + }, Section { group: "Account", names: &["account"], @@ -180,6 +184,8 @@ fn is_passthrough_command(token: &str) -> bool { "prime-agent", "command-code", "shannon", + "deepsec", + "vvah", ]; NAMES.contains(&token) } diff --git a/src/cli_formatter.rs b/src/cli_formatter.rs index d4689e2d..8f34e927 100644 --- a/src/cli_formatter.rs +++ b/src/cli_formatter.rs @@ -1222,6 +1222,12 @@ mod tests { "Open Command Code with a model", "Coding tools", ), + leaf( + "deepsec", + "Scan your own repos with DeepSec", + "Security tools", + ), + leaf("vvah", "Scan your own repos with VVAH", "Security tools"), namespace( "account", "Account", diff --git a/src/commands/cmd_harness.rs b/src/commands/cmd_harness.rs index 2ebec97a..4af4e9f6 100644 --- a/src/commands/cmd_harness.rs +++ b/src/commands/cmd_harness.rs @@ -108,6 +108,72 @@ pub fn register_harness(app: &mut App) { harness::shannon::launch(p.get_str("--model").as_deref(), &p.get_strs("args")) }); + let deepsec = app.add_subcommand("deepsec", "Scan your own repos with DeepSec"); + deepsec.footer(&examples_footer(&[ + Example::new( + "wally deepsec -- init --repo ./my-repo", + "Start a scan on a repo you are authorised to test (runs init)", + ), + Example::new( + "wally deepsec -m glm-5.3-flash -- process", + "Choose the model", + ), + Example::new( + "wally deepsec -- status", + "Other DeepSec commands pass through", + ), + ])); + deepsec.add_option( + "-m,--model", + ValueType::Text, + "A model from your account (default: your default model)", + ); + deepsec + .add_option( + "args", + ValueType::Text, + "DeepSec's own command and options; with none, init", + ) + .multi(); + deepsec.prefix_command(true); + deepsec.callback(|p, _g| { + if harness::refuse_decisions_model(p.get_str("--model").as_deref()) { + return 2; + } + harness::deepsec::launch(p.get_str("--model").as_deref(), &p.get_strs("args")) + }); + + let vvah = app.add_subcommand("vvah", "Scan your own repos with VVAH"); + vvah.footer(&examples_footer(&[ + Example::new( + "wally vvah", + "Scan the current repo you are authorised to test", + ), + Example::new( + "wally vvah -m glm-5.3-flash -- scan --repo ./my-repo", + "Scan another path with a chosen model", + ), + Example::new("wally vvah -- report", "Other VVAH commands pass through"), + ])); + vvah.add_option( + "-m,--model", + ValueType::Text, + "A model from your account (default: your default model)", + ); + vvah.add_option( + "args", + ValueType::Text, + "VVAH's own command and options; with none, scan --repo . --remediate", + ) + .multi(); + vvah.prefix_command(true); + vvah.callback(|p, _g| { + if harness::refuse_decisions_model(p.get_str("--model").as_deref()) { + return 2; + } + harness::vvah::launch(p.get_str("--model").as_deref(), &p.get_strs("args")) + }); + // The OpenAI-shaped agents, one subcommand per row of the table. They need // no translator, so there is nothing here but resolving the model and // handing the endpoint over the way each one takes it. diff --git a/src/harness/deepsec.rs b/src/harness/deepsec.rs new file mode 100644 index 00000000..314b509d --- /dev/null +++ b/src/harness/deepsec.rs @@ -0,0 +1,812 @@ +//! `wally deepsec`: DeepSec pointed at the models on a RunAnywhere account. +//! +//! DeepSec's custom OpenAI-compatible route is the Pi backend. This launcher +//! injects that route, sets the key for the child only, and otherwise gets +//! out of the way. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde_json::json; + +use crate::account::{self, ConsoleClient}; +use crate::io::output as out; + +use super::harness::confirm; +use super::hosted::{self, CLOUD_KEY_VARIABLE}; + +/// The DeepSec release this launcher was written against. +pub const PINNED_VERSION: &str = "2.3.10"; + +/// The npm package that ships the `deepsec` command. +pub const PACKAGE: &str = "deepsec"; + +/// The provider name passed as `--ai-provider`. +pub const PROVIDER_ID: &str = "runanywhere"; + +/// Where DeepSec reads the key. Only the name is stored in its config. +pub const KEY_VARIABLE: &str = "WALLY_DEEPSEC_API_KEY"; + +/// Pi reads `models.json` from this directory. Isolated under the Wally profile +/// so we do not rewrite `~/.pi/agent`. +pub const PI_AGENT_DIR_VARIABLE: &str = "PI_CODING_AGENT_DIR"; + +pub const PREREQUISITES: &str = + "DeepSec needs Node 18 or later (it provides npx). Install Node from https://nodejs.org, \ +then run this again."; + +pub fn npx_invoke() -> String { + format!("npx --yes {PACKAGE}@{PINNED_VERSION}") +} + +/// Bare `wally deepsec` runs DeepSec `init` in the current directory. Home is +/// not a repo to scan: DeepSec will treat it as the workspace (and may walk +/// `~/.Trash`). +pub fn refuse_home_workspace(args: &[String], cwd: &Path, home: Option<&Path>) -> bool { + if !args.is_empty() { + return false; + } + let Some(home) = home else { + return false; + }; + paths_are_same_dir(cwd, home) +} + +fn paths_are_same_dir(a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +const VERSION_PROBE_SECONDS: u64 = 20; + +const ROUTE_COMMANDS: [&str; 4] = ["init", "setup", "process", "revalidate"]; + +const OWNED_FLAGS: [&str; 3] = ["--model-auth", "--ai-base-url", "--ai-api-key-env"]; + +const OVERRIDING_VARIABLES: [&str; 4] = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "AI_GATEWAY_API_KEY", + CLOUD_KEY_VARIABLE, +]; + +// --------------------------------------------------------------------------- +// The command line +// --------------------------------------------------------------------------- + +/// What the person asked DeepSec to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Invocation { + /// A command that takes Wally's Pi custom route. + Routed { command: String, rest: Vec }, + /// Anything else, forwarded untouched. + Passthrough(Vec), +} + +fn is_help_or_version_flag(arg: &str) -> bool { + matches!(arg, "-h" | "--help" | "-v" | "--version") +} + +fn names_owned_flag(arg: &str) -> bool { + OWNED_FLAGS + .iter() + .any(|flag| arg == *flag || arg.starts_with(&format!("{flag}="))) +} + +fn route_error() -> String { + "wally deepsec sets the model route itself, from your account. Remove --model-auth, \ + --ai-base-url and --ai-api-key-env, or run DeepSec directly to use your own route." + .to_string() +} + +/// Reads DeepSec's arguments. No subcommand, or a first argument that is an +/// option, means `init`; `setup`/`process`/`revalidate` also get the route. +pub fn classify(args: &[String]) -> Result { + let first = args.first().map(String::as_str); + let (command, rest): (String, Vec) = match first { + None => ("init".to_string(), Vec::new()), + Some(flag) if is_help_or_version_flag(flag) => { + return Ok(Invocation::Passthrough(args.to_vec())) + } + Some(flag) if flag.starts_with('-') => ("init".to_string(), args.to_vec()), + Some(command) if ROUTE_COMMANDS.contains(&command) => { + (command.to_string(), args[1..].to_vec()) + } + Some(_) => return Ok(Invocation::Passthrough(args.to_vec())), + }; + if rest.iter().any(|arg| names_owned_flag(arg)) { + return Err(route_error()); + } + if rest + .iter() + .any(|arg| matches!(arg.as_str(), "-h" | "--help")) + { + let mut argv = vec![command]; + argv.extend(rest); + return Ok(Invocation::Passthrough(argv)); + } + Ok(Invocation::Routed { command, rest }) +} + +/// Pi requires `provider/model` and looks that pair up in `models.json`. +/// Custom OpenAI-compatible routes are registered as `runanywhere/`. +pub fn pi_model_id(model: &str) -> String { + if model.contains('/') { + model.to_string() + } else { + format!("{PROVIDER_ID}/{model}") + } +} + +pub fn models_json_model_id(model: &str) -> &str { + model.rsplit_once('/').map(|(_, id)| id).unwrap_or(model) +} + +/// Pi's registry for the hosted provider. No key; auth is the child env var. +pub fn models_json(base_url: &str, model: &str) -> String { + serde_json::to_string_pretty(&json!({ + "providers": { + PROVIDER_ID: { + "baseUrl": base_url, + "api": "openai-completions", + "authHeader": true, + "compat": { "supportsDeveloperRole": false }, + "models": [{ + "id": models_json_model_id(model), + "name": models_json_model_id(model), + "input": ["text"] + }] + } + } + })) + .expect("models.json is a closed object") +} + +pub fn pi_agent_directory() -> Option { + let profile = account::profile_directory(); + if profile.is_empty() { + return None; + } + Some(PathBuf::from(profile).join("deepsec").join("agent")) +} + +pub fn write_pi_models_json(dir: &Path, base_url: &str, model: &str) -> Result { + if std::fs::create_dir_all(dir).is_err() { + return Err(format!("could not create {}", dir.display())); + } + let path = dir.join("models.json"); + std::fs::write(&path, models_json(base_url, model)) + .map_err(|error| format!("could not write {}: {error}", path.display()))?; + Ok(path) +} + +/// The Pi custom-route flags, then the person's own options. +pub fn route_flags(model: &str, base_url: &str) -> Vec { + vec![ + "--agent".to_string(), + "pi".to_string(), + "--model".to_string(), + pi_model_id(model), + "--model-auth".to_string(), + "custom".to_string(), + "--ai-provider".to_string(), + PROVIDER_ID.to_string(), + "--ai-base-url".to_string(), + base_url.to_string(), + "--ai-api-key-env".to_string(), + KEY_VARIABLE.to_string(), + "--ai-credential-header".to_string(), + "authorization:bearer".to_string(), + ] +} + +impl Invocation { + pub fn argv(&self, model: &str, base_url: &str) -> Vec { + match self { + Invocation::Routed { command, rest } => { + let mut argv = vec![command.clone()]; + argv.extend(route_flags(model, base_url)); + argv.extend(rest.iter().cloned()); + argv + } + Invocation::Passthrough(args) => args.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// The child's environment +// --------------------------------------------------------------------------- + +/// The environment changes for the DeepSec child. Holds the key, so it has no +/// `Debug`. +pub struct ChildEnv { + pub set: Vec<(&'static str, String)>, + pub remove: Vec<&'static str>, +} + +impl ChildEnv { + pub fn for_start(key: &str, agent_dir: &Path) -> Self { + ChildEnv { + set: vec![ + (KEY_VARIABLE, key.to_string()), + ( + PI_AGENT_DIR_VARIABLE, + agent_dir.to_string_lossy().into_owned(), + ), + ], + remove: OVERRIDING_VARIABLES.to_vec(), + } + } + + pub fn unchanged() -> Self { + ChildEnv { + set: Vec::new(), + remove: Vec::new(), + } + } + + fn apply(&self, command: &mut Command) { + for name in &self.remove { + command.env_remove(name); + } + for (name, value) in &self.set { + command.env(name, value); + } + } +} + +// --------------------------------------------------------------------------- +// Which DeepSec runs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Runner { + Installed(PathBuf), + Npx(PathBuf), +} + +impl Runner { + fn program(&self) -> &Path { + match self { + Runner::Installed(path) | Runner::Npx(path) => path, + } + } + + fn prefix(&self) -> Vec { + match self { + Runner::Installed(_) => Vec::new(), + Runner::Npx(_) => vec!["--yes".to_string(), format!("{PACKAGE}@{PINNED_VERSION}")], + } + } +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +pub fn find_on_path(name: &str, search_path: &OsStr) -> Option { + let names: Vec = if cfg!(windows) { + vec![ + format!("{name}.exe"), + format!("{name}.cmd"), + name.to_string(), + ] + } else { + vec![name.to_string()] + }; + for dir in std::env::split_paths(search_path) { + if dir.as_os_str().is_empty() { + continue; + } + for candidate in &names { + let path = dir.join(candidate); + if is_executable(&path) { + return Some(path); + } + } + } + None +} + +pub fn parse_version(output: &str) -> Option { + output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .and_then(|line| { + line.split_whitespace().find_map(|word| { + let word = word.strip_prefix('v').unwrap_or(word); + word.chars() + .next() + .is_some_and(|c| c.is_ascii_digit()) + .then(|| word.to_string()) + }) + }) +} + +fn retry_busy(mut start: impl FnMut() -> std::io::Result) -> std::io::Result { + let mut attempts = 0; + loop { + match start() { + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 40 => + { + attempts += 1; + std::thread::sleep(Duration::from_millis(25)); + } + other => return other, + } + } +} + +pub fn probe_version(deepsec: &Path) -> Option { + let mut child = retry_busy(|| { + Command::new(deepsec) + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + }) + .ok()?; + let deadline = Instant::now() + Duration::from_secs(VERSION_PROBE_SECONDS); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(25)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + if !status.success() { + return None; + } + let mut text = String::new(); + std::io::Read::read_to_string(&mut child.stdout.take()?, &mut text).ok()?; + parse_version(&text) +} + +pub fn select_runner( + search_path: &OsStr, + probe: &dyn Fn(&Path) -> Option, +) -> Result<(Runner, Option), String> { + let mut note = None; + if let Some(installed) = find_on_path("deepsec", search_path) { + match probe(&installed) { + Some(version) + if version == PINNED_VERSION + || version.strip_prefix('v') == Some(PINNED_VERSION) => + { + return Ok((Runner::Installed(installed), None)); + } + Some(version) => { + note = Some(format!( + "the deepsec on PATH is {version}, not {PINNED_VERSION}; using npx \ + {PACKAGE}@{PINNED_VERSION} instead" + )); + } + None => { + note = Some(format!( + "could not read the version of the deepsec on PATH; using npx \ + {PACKAGE}@{PINNED_VERSION} instead" + )); + } + } + } + match find_on_path("npx", search_path) { + Some(npx) => Ok((Runner::Npx(npx), note)), + None => Err(match note { + Some(note) => format!("{note}, but npx was not found. {PREREQUISITES}"), + None => format!("deepsec and npx were not found. {PREREQUISITES}"), + }), + } +} + +// --------------------------------------------------------------------------- +// Running it +// --------------------------------------------------------------------------- + +pub fn run_child(runner: &Runner, argv: &[String], env: &ChildEnv) -> i32 { + let interrupted = Arc::new(AtomicBool::new(false)); + let noted = Arc::clone(&interrupted); + let _interrupt = crate::util::interrupt::on_interrupt(move || { + noted.store(true, Ordering::SeqCst); + }); + + let mut command = Command::new(runner.program()); + command.args(runner.prefix()).args(argv); + env.apply(&mut command); + let status = match retry_busy(|| command.status()) { + Ok(status) => status, + Err(error) => { + out::error_line(&format!("could not start DeepSec: {error}")); + return 127; + } + }; + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + if interrupted.load(Ordering::SeqCst) { + 130 + } else { + 1 + } +} + +// --------------------------------------------------------------------------- +// The command +// --------------------------------------------------------------------------- + +/// `wally deepsec [-m ] [--] [] [args...]`. +pub fn launch(requested_model: Option<&str>, args: &[String]) -> i32 { + if let Err(code) = hosted::require_safe_model(requested_model) { + return code; + } + let invocation = match classify(args) { + Ok(invocation) => invocation, + Err(message) => { + out::error_line(&message); + return 2; + } + }; + + let cwd = std::env::current_dir().unwrap_or_default(); + let home = std::env::var_os("HOME").map(PathBuf::from); + if refuse_home_workspace(args, &cwd, home.as_deref()) { + out::error_line( + "wally deepsec with no extra args runs DeepSec init in the current directory. \ + That is your home directory, which DeepSec would treat as the workspace. \ + cd into a repo you are authorised to test.", + ); + return 2; + } + + let search_path = std::env::var_os("PATH").unwrap_or_default(); + let (runner, note) = match select_runner(&search_path, &probe_version) { + Ok(selected) => selected, + Err(message) => { + out::error_line(&message); + return 127; + } + }; + if let Some(note) = note { + out::status_line(¬e); + } + if matches!(runner, Runner::Npx(_)) { + let command = npx_invoke(); + out::error_line("deepsec is not installed on this machine"); + out::status_line(&format!("run it with `{command}`")); + out::status_line( + "DeepSec init also links a Vercel project for Sandbox identity; models still use your RunAnywhere account.", + ); + if !confirm(&format!("Run it now with `{command}`? [y/N] ")) { + return 127; + } + } + + let Invocation::Routed { .. } = &invocation else { + if requested_model.is_some() { + out::status_line("-m only applies to init, setup, process and revalidate; passing the command through unchanged"); + } + return run_child(&runner, &invocation.argv("", ""), &ChildEnv::unchanged()); + }; + + let console = ConsoleClient::default(); + let Some(hosted) = hosted::hosted_access(&console, "DeepSec") else { + return 1; + }; + let (model, base_url) = match hosted::pick_model(&console, &hosted, requested_model) { + Ok(chosen) => chosen, + Err(code) => return code, + }; + + out::status_line(&format!("DeepSec will use {model} through {base_url}")); + let Some(agent_dir) = pi_agent_directory() else { + out::error_line("no user profile directory is available"); + return 1; + }; + if let Err(message) = write_pi_models_json(&agent_dir, &base_url, &model) { + out::error_line(&message); + return 1; + } + run_child( + &runner, + &invocation.argv(&model, &base_url), + &ChildEnv::for_start(&hosted.key, &agent_dir), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + use std::fs; + + use crate::util::env_lock::lock as env_lock; + + fn strings(tokens: &[&str]) -> Vec { + tokens.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn pinned_package_and_provider_constants() { + assert_eq!(PINNED_VERSION, "2.3.10"); + assert_eq!(PACKAGE, "deepsec"); + assert_eq!(PROVIDER_ID, "runanywhere"); + assert_eq!(KEY_VARIABLE, "WALLY_DEEPSEC_API_KEY"); + assert_eq!(npx_invoke(), "npx --yes deepsec@2.3.10"); + } + + #[test] + fn refuse_home_workspace_only_for_bare_init() { + let home = Path::new("/Users/me"); + assert!(refuse_home_workspace(&[], home, Some(home))); + assert!(!refuse_home_workspace( + &strings(&["init", "--repo", "./x"]), + home, + Some(home) + )); + assert!(!refuse_home_workspace( + &[], + Path::new("/Users/me/src"), + Some(home) + )); + } + + #[test] + fn classify_empty_or_option_starts_routed_init() { + assert_eq!( + classify(&[]).unwrap(), + Invocation::Routed { + command: "init".to_string(), + rest: vec![], + } + ); + assert_eq!( + classify(&strings(&["--repo", "./x"])).unwrap(), + Invocation::Routed { + command: "init".to_string(), + rest: strings(&["--repo", "./x"]), + } + ); + } + + #[test] + fn classify_help_and_version_pass_through() { + for flag in ["-h", "--help", "-v", "--version"] { + let args = strings(&[flag]); + assert_eq!(classify(&args).unwrap(), Invocation::Passthrough(args)); + } + } + + #[test] + fn classify_routed_subcommands_and_scan_passthrough() { + assert_eq!( + classify(&strings(&["process", "--all"])).unwrap(), + Invocation::Routed { + command: "process".to_string(), + rest: strings(&["--all"]), + } + ); + assert_eq!( + classify(&strings(&["status"])).unwrap(), + Invocation::Passthrough(strings(&["status"])) + ); + } + + #[test] + fn classify_refuses_owned_route_flags_on_routed_commands() { + for flag in ["--model-auth", "--ai-base-url", "--ai-api-key-env"] { + assert!(classify(&strings(&["init", flag])).is_err()); + assert!(classify(&strings(&["revalidate", &format!("{flag}=x")])).is_err()); + } + } + + #[test] + fn classify_help_on_routed_command_passes_through() { + assert_eq!( + classify(&strings(&["init", "--help"])).unwrap(), + Invocation::Passthrough(strings(&["init", "--help"])) + ); + } + + #[test] + fn pi_model_id_prefixes_runanywhere_when_bare() { + assert_eq!(pi_model_id("glm-5.3-flash"), "runanywhere/glm-5.3-flash"); + assert_eq!(pi_model_id("zai/glm-5.2"), "zai/glm-5.2"); + } + + #[test] + fn route_flags_inject_pi_custom_route_before_rest() { + assert_eq!( + route_flags("glm-5.3-flash", "https://api.example/v1"), + strings(&[ + "--agent", + "pi", + "--model", + "runanywhere/glm-5.3-flash", + "--model-auth", + "custom", + "--ai-provider", + "runanywhere", + "--ai-base-url", + "https://api.example/v1", + "--ai-api-key-env", + "WALLY_DEEPSEC_API_KEY", + "--ai-credential-header", + "authorization:bearer", + ]) + ); + let routed = Invocation::Routed { + command: "init".to_string(), + rest: strings(&["--repo", "."]), + }; + assert_eq!( + routed.argv("m", "https://x/v1"), + strings(&[ + "init", + "--agent", + "pi", + "--model", + "runanywhere/m", + "--model-auth", + "custom", + "--ai-provider", + "runanywhere", + "--ai-base-url", + "https://x/v1", + "--ai-api-key-env", + "WALLY_DEEPSEC_API_KEY", + "--ai-credential-header", + "authorization:bearer", + "--repo", + ".", + ]) + ); + } + + #[test] + fn models_json_registers_runanywhere_provider_without_a_key() { + let text = models_json("https://api.example/v1", "glm-5.3-flash"); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!( + parsed["providers"]["runanywhere"]["baseUrl"], + "https://api.example/v1" + ); + assert_eq!( + parsed["providers"]["runanywhere"]["api"], + "openai-completions" + ); + assert_eq!( + parsed["providers"]["runanywhere"]["models"][0]["id"], + "glm-5.3-flash" + ); + assert!(!text.contains("apiKey")); + assert!(!text.contains("sk-")); + assert_eq!(models_json_model_id("runanywhere/m"), "m"); + } + + #[test] + fn child_env_for_start_sets_key_and_strips_overrides() { + let env = ChildEnv::for_start("secret", Path::new("/tmp/wally-deepsec-agent")); + assert_eq!( + env.set, + vec![ + (KEY_VARIABLE, "secret".to_string()), + ( + PI_AGENT_DIR_VARIABLE, + "/tmp/wally-deepsec-agent".to_string() + ), + ] + ); + assert_eq!( + env.remove, + vec![ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "AI_GATEWAY_API_KEY", + CLOUD_KEY_VARIABLE, + ] + ); + assert!(ChildEnv::unchanged().set.is_empty()); + assert!(ChildEnv::unchanged().remove.is_empty()); + } + + #[test] + fn parse_version_reads_first_numeric_token() { + assert_eq!( + parse_version("deepsec 2.3.10\n"), + Some("2.3.10".to_string()) + ); + assert_eq!(parse_version("version 2.3.10"), Some("2.3.10".to_string())); + } + + #[cfg(unix)] + #[test] + fn select_runner_prefers_pinned_install() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let deepsec = dir.path().join("deepsec"); + fs::write(&deepsec, "#!/bin/sh\necho deepsec 2.3.10\n").unwrap(); + fs::set_permissions(&deepsec, fs::Permissions::from_mode(0o700)).unwrap(); + let (runner, note) = select_runner(dir.path().as_os_str(), &|_| { + Some(PINNED_VERSION.to_string()) + }) + .unwrap(); + assert!(note.is_none()); + assert_eq!(runner, Runner::Installed(deepsec)); + } + + #[test] + fn select_runner_without_deepsec_or_npx_names_node() { + let err = select_runner(OsStr::new("/empty-wally-path"), &|_| None).unwrap_err(); + assert!(err.contains("npx were not found")); + assert!(err.contains("Node 18")); + } + + #[cfg(unix)] + #[test] + fn run_child_applies_env_only_on_the_child() { + use std::os::unix::fs::PermissionsExt; + + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("tempdir"); + let stub = dir.path().join("deepsec-stub"); + fs::write( + &stub, + "#!/bin/sh\nif test -n \"$WALLY_DEEPSEC_API_KEY\" && test -z \"$OPENAI_API_KEY\"; then exit 42; fi\nexit 9\n", + ) + .unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o700)).unwrap(); + + let previous = std::env::var("OPENAI_API_KEY").ok(); + std::env::set_var("OPENAI_API_KEY", "parent"); + let code = run_child( + &Runner::Installed(stub), + &strings(&["status"]), + &ChildEnv::for_start("child-key", Path::new("/tmp/wally-deepsec-agent")), + ); + let remaining = std::env::var("OPENAI_API_KEY"); + match previous { + Some(value) => std::env::set_var("OPENAI_API_KEY", value), + None => std::env::remove_var("OPENAI_API_KEY"), + } + assert_eq!(code, 42); + assert_eq!(remaining.as_deref(), Ok("parent")); + } +} diff --git a/src/harness/hosted.rs b/src/harness/hosted.rs new file mode 100644 index 00000000..b32565b1 --- /dev/null +++ b/src/harness/hosted.rs @@ -0,0 +1,217 @@ +//! Shared hosted-account access for security-tool launchers. + +use crate::account::{self, ConsoleClient, IdentityResult}; +use crate::harness::catalog_models::{catalog_models_with, CatalogModel}; +use crate::harness::harness::{ + model_id_is_safe, refresh_session, report_cloud_session_invalid, report_not_signed_in, + verify_cloud_session, +}; +use crate::io::output as out; + +/// A console Cloud key, for a scan that outlasts a signed-in session. +pub const CLOUD_KEY_VARIABLE: &str = "WALLY_CLOUD_KEY"; + +/// A session that would end sooner than this is refreshed before the scan +/// starts: a scan runs for hours, and these tools cannot refresh anything. +const REFRESH_WITHIN_SECONDS: i64 = 4 * 60 * 60; + +/// Where the models are served from and the key that reads them. +pub struct Hosted { + pub console_url: String, + pub key: String, + pub session: bool, +} + +fn cloud_key_from_environment() -> Option { + let value = std::env::var(CLOUD_KEY_VARIABLE).ok()?; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +/// `WALLY_CLOUD_KEY` if set, else the signed-in session, refreshed first when +/// it would end within four hours. +pub fn hosted_access(console: &ConsoleClient, tool: &str) -> Option { + let mut credentials = match account::load() { + Ok(credentials) => credentials, + Err(message) => { + out::error_line(&message); + return None; + } + }; + + if let Some(key) = cloud_key_from_environment() { + if !account::session_token_is_safe(&key) { + out::error_line(&format!( + "{CLOUD_KEY_VARIABLE} holds characters a key cannot contain" + )); + return None; + } + return Some(Hosted { + console_url: credentials.console_url, + key, + session: false, + }); + } + + if !credentials.signed_in() { + report_not_signed_in(); + out::status_line(&format!( + "or set {CLOUD_KEY_VARIABLE} to a Cloud key from the console" + )); + return None; + } + if !credentials.refresh_token.is_empty() + && credentials.access_token_expired(account::epoch_seconds(), REFRESH_WITHIN_SECONDS) + { + if let Err(error) = refresh_session(console, &mut credentials) { + let usable = + error.unverified && !credentials.access_token_expired(account::epoch_seconds(), 60); + if !usable { + report_cloud_session_invalid(tool); + return None; + } + } + } + match verify_cloud_session(console, &mut credentials) { + Ok(email) => { + if !email.is_empty() { + out::status_line(&format!("signed in as {email}")); + } + } + Err(error) if error.unverified => { + out::status_line(&format!( + "could not confirm the cloud session ({}) - continuing on the stored session", + error.message + )); + } + Err(_) => { + report_cloud_session_invalid(tool); + return None; + } + } + let remaining = credentials.expires_at - account::epoch_seconds(); + if credentials.expires_at > 0 && remaining < REFRESH_WITHIN_SECONDS { + out::status_line(&format!( + "this sign-in stays valid for about {} minutes. For a longer scan, create a Cloud \ + key in the console and set {CLOUD_KEY_VARIABLE}.", + remaining.max(0) / 60 + )); + } + Some(Hosted { + console_url: credentials.console_url, + key: credentials.access_token, + session: true, + }) +} + +pub fn not_available_message(model: &str) -> String { + format!( + "'{model}' is not available on this account. If it needs approval, request access in \ + the console." + ) +} + +/// The model the scan is pointed at: `requested` if the account has it, else +/// the preferred default if it has that, else the first model on the list. +pub fn choose_model( + models: &[CatalogModel], + requested: Option<&str>, + preferred_default: &str, +) -> Result { + if let Some(id) = requested { + return if models.iter().any(|model| model.id == id) { + Ok(id.to_string()) + } else { + Err(not_available_message(id)) + }; + } + if models.iter().any(|model| model.id == preferred_default) { + return Ok(preferred_default.to_string()); + } + models + .first() + .map(|model| model.id.clone()) + .ok_or_else(|| "your account has no models available for a security scan".to_string()) +} + +pub fn require_safe_model(requested: Option<&str>) -> Result<(), i32> { + if let Some(id) = requested { + if !model_id_is_safe(id) { + out::error_line(&format!("'{id}' is not a valid model id")); + return Err(2); + } + } + Ok(()) +} + +pub fn load_models( + console: &ConsoleClient, + hosted: &Hosted, + primary: &str, +) -> Result, i32> { + let (result, _, error) = console.fetch_models(&hosted.console_url, &hosted.key); + match result { + IdentityResult::Ok => {} + IdentityResult::Unauthorized if hosted.session => { + report_cloud_session_invalid("this scan"); + return Err(1); + } + IdentityResult::Unauthorized => { + out::error_line(&format!( + "the console did not accept {CLOUD_KEY_VARIABLE}; check the key in the console" + )); + return Err(1); + } + _ => { + out::error_line(&format!("could not read your model list: {error}")); + return Err(1); + } + } + let mut models = catalog_models_with(console, &hosted.console_url, &hosted.key, primary); + models.retain(|model| !super::is_decisions_model(&model.id)); + Ok(models) +} + +pub fn pick_model( + console: &ConsoleClient, + hosted: &Hosted, + requested: Option<&str>, +) -> Result<(String, String), i32> { + let preferred = crate::config::preferences::resolve_model(""); + let primary = requested.unwrap_or(preferred.as_str()); + let models = load_models(console, hosted, primary)?; + let model = match choose_model(&models, requested, &preferred) { + Ok(model) => model, + Err(message) => { + out::error_line(&message); + return Err(1); + } + }; + if requested.is_none() { + out::status_line(&format!("model not provided, using default model {model}")); + } + let base_url = format!("{}/v1", hosted.console_url); + Ok((model, base_url)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model(id: &str) -> CatalogModel { + CatalogModel { + id: id.to_string(), + ..CatalogModel::default() + } + } + + #[test] + fn choose_model_prefers_requested_then_default_then_first() { + let models = vec![model("a"), model("b"), model("c")]; + assert_eq!(choose_model(&models, Some("b"), "a").unwrap(), "b"); + assert_eq!(choose_model(&models, None, "c").unwrap(), "c"); + assert_eq!(choose_model(&models, None, "missing").unwrap(), "a"); + assert!(choose_model(&models, Some("nope"), "a").is_err()); + assert!(choose_model(&[], None, "a").is_err()); + } +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 437b0625..d4bb148e 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -9,13 +9,16 @@ pub mod claude_effort; pub mod codex; pub mod command_code; pub mod declared_harness; +pub mod deepsec; #[allow(clippy::module_inception)] pub mod harness; +pub mod hosted; pub mod image_gate; pub mod local_models; pub mod opencode; pub mod path_reload; pub mod shannon; +pub mod vvah; pub use agents::*; pub use catalog_models::*; diff --git a/src/harness/vvah.rs b/src/harness/vvah.rs new file mode 100644 index 00000000..a3fd403c --- /dev/null +++ b/src/harness/vvah.rs @@ -0,0 +1,950 @@ +//! `wally vvah`: Visa Vulnerability Agentic Harness against account models. +//! +//! VVAH is a Python tool. This launcher installs a pinned release into the +//! Wally profile when asked, writes a `config.local.yaml` of OpenAI-compatible +//! roles, and sets `OPENAI_BASE_URL` / `OPENAI_API_KEY` for the child only. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use crate::account::{self, ConsoleClient}; +use crate::io::output as out; + +use super::harness::confirm; +use super::hosted::{self, CLOUD_KEY_VARIABLE}; + +/// The VVAH release this launcher installs and runs. +pub const PINNED_VERSION: &str = "1.5.1"; + +pub const MISSING_PYTHON: &str = + "python 3.11 is not installed on this machine, Please install it first, Thank You"; + +pub const SOURCE: &str = + "git+https://github.com/visa/visa-vulnerability-agentic-harness.git@v1.5.1"; + +pub const S10_WARNING: &str = + "VVAH's remediate step can edit files in the target repo. Scan only code you are authorised to test."; + +const MIN_PYTHON: (u32, u32) = (3, 11); +/// Inclusive. `arcadedb-embedded==26.10.1` (a VVAH pin) ships wheels for +/// 3.10–3.14 only, so 3.15's pip reports "from versions: none". +const MAX_PYTHON: (u32, u32) = (3, 14); + +const PYTHON_CANDIDATES: &[&str] = &[ + "python3.12", + "python3.11", + "python3.13", + "python3.14", + "python3", +]; + +const KEY_VARIABLE: &str = "OPENAI_API_KEY"; +const BASE_URL_VARIABLE: &str = "OPENAI_BASE_URL"; + +const ROUTE_COMMANDS: [&str; 3] = ["scan", "remediate", "doctor"]; + +const OVERRIDING_VARIABLES: [&str; 4] = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_SDK_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + CLOUD_KEY_VARIABLE, +]; + +// --------------------------------------------------------------------------- +// The command line +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Invocation { + Routed { command: String, rest: Vec }, + Passthrough(Vec), +} + +fn is_help_or_version_flag(arg: &str) -> bool { + matches!(arg, "-h" | "--help" | "-v" | "--version") +} + +fn names_config(arg: &str) -> bool { + arg == "--config" || arg.starts_with("--config=") +} + +fn config_error() -> String { + "wally vvah writes the --config file itself, from your account's model. Remove --config, \ + or run VVAH directly to use your own file." + .to_string() +} + +/// No subcommand means `scan --repo . --remediate`. `scan`/`remediate`/`doctor` +/// get the Wally config; everything else is left alone. +pub fn classify(args: &[String]) -> Result { + let first = args.first().map(String::as_str); + let (command, rest): (String, Vec) = match first { + None => ( + "scan".to_string(), + vec![ + "--repo".to_string(), + ".".to_string(), + "--remediate".to_string(), + ], + ), + Some(flag) if is_help_or_version_flag(flag) => { + return Ok(Invocation::Passthrough(args.to_vec())) + } + Some(flag) if flag.starts_with('-') => ("scan".to_string(), args.to_vec()), + Some(command) if ROUTE_COMMANDS.contains(&command) => { + (command.to_string(), args[1..].to_vec()) + } + Some(_) => return Ok(Invocation::Passthrough(args.to_vec())), + }; + if rest.iter().any(|arg| names_config(arg)) { + return Err(config_error()); + } + if rest + .iter() + .any(|arg| matches!(arg.as_str(), "-h" | "--help")) + { + let mut argv = vec![command]; + argv.extend(rest); + return Ok(Invocation::Passthrough(argv)); + } + Ok(Invocation::Routed { command, rest }) +} + +impl Invocation { + pub fn argv(&self, config: &str) -> Vec { + match self { + Invocation::Routed { command, rest } => { + let mut argv = vec![command.clone(), "--config".to_string(), config.to_string()]; + argv.extend(rest.iter().cloned()); + argv + } + Invocation::Passthrough(args) => args.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +fn role_line(id: &str, model: &str) -> String { + format!(" {id}: {{id: {model}, via: openai}}") +} + +/// OpenAI-compatible overlay for every active VVAH role. No key. +pub fn config_yaml(model: &str) -> String { + let mut out = String::from("models:\n"); + for id in [ + "autoexclude", + "preprocess", + "threatmodel", + "decompose", + "deepdive", + "verify", + "dedup", + "chain", + "remediate", + ] { + out.push_str(&role_line(id, model)); + out.push('\n'); + } + out.push_str(" validate:\n"); + for id in [ + "orchestrator", + "security_architect", + "penetration_tester", + "cross_repo_analyzer", + ] { + out.push_str(&format!(" {id}: {{id: {model}, via: openai}}\n")); + } + out.push_str(" exploit_verification:\n"); + for id in ["attacker", "judge", "classify", "mapper"] { + out.push_str(&format!(" {id}: {{id: {model}, via: openai}}\n")); + } + out.push_str( + "step2:\n max_turns: 60\n\ + step4:\n max_turns: 60\n\ + step6_verify:\n max_turns: 60\n\ + step_remediate:\n enabled: true\n\ + step_validate:\n enabled: true\n", + ); + out +} + +pub fn config_path() -> Option { + let profile = account::profile_directory(); + if profile.is_empty() { + return None; + } + Some( + PathBuf::from(profile) + .join("vvah") + .join("config.local.yaml"), + ) +} + +pub fn repo_from_rest(rest: &[String]) -> &str { + let mut i = 0; + while i < rest.len() { + if rest[i] == "--repo" { + return rest.get(i + 1).map(String::as_str).unwrap_or("."); + } + if let Some(value) = rest[i].strip_prefix("--repo=") { + return value; + } + i += 1; + } + "." +} + +fn existing_or_parent(path: &Path) -> Option { + std::fs::canonicalize(path) + .ok() + .or_else(|| path.parent().and_then(|parent| parent.canonicalize().ok())) +} + +/// True when `path` is `root` or a file under it (VVAH's CONFIG_POLICY_REFUSED). +pub fn path_is_inside(path: &Path, root: &Path) -> bool { + let Some(path) = existing_or_parent(path) else { + return false; + }; + let Ok(root) = std::fs::canonicalize(root) else { + return false; + }; + path == root || path.starts_with(&root) +} + +/// Profile config sits under `$HOME/.config/wally`. A default scan of `.` from +/// home is that tree, so VVAH refuses the file. Put the config directory in +/// temp instead. `--config` must be `config.yaml`; VVAH then loads sibling +/// `config.local.yaml` as the overlay. +pub fn config_dir_for_target( + preferred_dir: PathBuf, + cwd: &Path, + repo: &str, +) -> Result { + let root = { + let repo = Path::new(repo); + if repo.is_absolute() { + repo.to_path_buf() + } else { + cwd.join(repo) + } + }; + if !path_is_inside(&preferred_dir, &root) { + return Ok(preferred_dir); + } + let fallback = std::env::temp_dir().join("wally-vvah"); + if path_is_inside(&fallback, &root) { + return Err( + "the scan target contains Wally's VVAH config path; pass --repo at a project directory" + .to_string(), + ); + } + Ok(fallback) +} + +pub fn packaged_default_yaml(vvaharness: &Path) -> Option { + let venv = vvaharness.parent()?.parent()?; + let relative = Path::new("vvaharness") + .join("config") + .join("profiles") + .join("default.yaml"); + let windows = venv.join("Lib").join("site-packages").join(&relative); + if windows.is_file() { + return Some(windows); + } + let lib = venv.join("lib"); + let entries = std::fs::read_dir(&lib).ok()?; + for entry in entries.flatten() { + let candidate = entry.path().join("site-packages").join(&relative); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +pub fn stage_vvah_config(dir: &Path, program: &Path, model: &str) -> Result { + if std::fs::create_dir_all(dir).is_err() { + return Err(format!("could not create {}", dir.display())); + } + let overlay = dir.join("config.local.yaml"); + write_private_file(&overlay, &config_yaml(model)) + .map_err(|error| format!("could not write {}: {error}", overlay.display()))?; + let primary = dir.join("config.yaml"); + match packaged_default_yaml(program) { + Some(src) => { + std::fs::copy(&src, &primary) + .map_err(|error| format!("could not copy VVAH default.yaml: {error}"))?; + Ok(primary) + } + None => Ok(overlay), + } +} + +pub fn refuse_home_workspace(args: &[String], cwd: &Path, home: Option<&Path>) -> bool { + if !args.is_empty() { + return false; + } + let Some(home) = home else { + return false; + }; + if cwd == home { + return true; + } + match (std::fs::canonicalize(cwd), std::fs::canonicalize(home)) { + (Ok(cwd), Ok(home)) => cwd == home, + _ => false, + } +} + +pub fn write_private_file(path: &Path, text: &str) -> std::io::Result<()> { + let Some(parent) = path.parent() else { + return Err(std::io::Error::other("no parent directory")); + }; + if !parent.is_dir() { + std::fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + } + let mut temporary_name = std::ffi::OsString::from("."); + temporary_name.push(path.file_name().unwrap_or_default()); + temporary_name.push(format!(".{}.tmp", std::process::id())); + let temporary = parent.join(temporary_name); + let _ = std::fs::remove_file(&temporary); + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let written = options.open(&temporary).and_then(|mut file| { + file.write_all(text.as_bytes())?; + file.sync_all() + }); + match written.and_then(|()| std::fs::rename(&temporary, path)) { + Ok(()) => Ok(()), + Err(error) => { + let _ = std::fs::remove_file(&temporary); + Err(error) + } + } +} + +// --------------------------------------------------------------------------- +// Python / install +// --------------------------------------------------------------------------- + +pub fn parse_python_version(output: &str) -> Option<(u32, u32)> { + let line = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty())?; + let version = line.split_whitespace().last()?; + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + Some((major, minor)) +} + +pub fn python_is_new_enough(major: u32, minor: u32) -> bool { + (major, minor) >= MIN_PYTHON +} + +pub fn python_is_supported(major: u32, minor: u32) -> bool { + python_is_new_enough(major, minor) && (major, minor) <= MAX_PYTHON +} + +pub fn too_new_python_message(major: u32, minor: u32) -> String { + format!( + "VVAH needs Python 3.11–3.14 (python3 is {major}.{minor}). Install Python 3.12, then run this again." + ) +} + +pub fn install_dir() -> Option { + let profile = account::profile_directory(); + if profile.is_empty() { + return None; + } + Some(PathBuf::from(profile).join("vvah")) +} + +fn venv_bin() -> &'static str { + if cfg!(windows) { + "Scripts" + } else { + "bin" + } +} + +pub fn venv_vvaharness(dir: &Path) -> PathBuf { + let mut name = dir.join("venv").join(venv_bin()).join("vvaharness"); + if cfg!(windows) { + name.set_extension("exe"); + } + name +} + +pub fn install_command(dir: &Path, python: &Path) -> String { + let venv = dir.join("venv"); + let pip = venv + .join(venv_bin()) + .join(if cfg!(windows) { "pip.exe" } else { "pip" }); + format!( + "{} -m venv {} && {} install {SOURCE}", + python.display(), + venv.display(), + pip.display() + ) +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +pub fn probe_python_at(program: &Path) -> Option<(u32, u32)> { + let output = Command::new(program) + .arg("--version") + .stdin(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_python_version(&String::from_utf8_lossy(&output.stdout)) + .or_else(|| parse_python_version(&String::from_utf8_lossy(&output.stderr))) +} + +pub fn probe_python() -> Option<(u32, u32)> { + probe_python_at(Path::new("python3")) +} + +/// First interpreter on PATH that VVAH's pinned ArcadeDB wheel will install on. +pub fn find_supported_python() -> Option<(PathBuf, u32, u32)> { + for name in PYTHON_CANDIDATES { + let Some(path) = find_on_path(name) else { + continue; + }; + let Some((major, minor)) = probe_python_at(&path) else { + continue; + }; + if python_is_supported(major, minor) { + return Some((path, major, minor)); + } + } + None +} + +fn retry_busy(mut start: impl FnMut() -> std::io::Result) -> std::io::Result { + let mut attempts = 0; + loop { + match start() { + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 40 => + { + attempts += 1; + std::thread::sleep(Duration::from_millis(25)); + } + other => return other, + } + } +} + +fn find_on_path(name: &str) -> Option { + let search_path = std::env::var_os("PATH")?; + let names: Vec = if cfg!(windows) { + vec![format!("{name}.exe"), name.to_string()] + } else { + vec![name.to_string()] + }; + for dir in std::env::split_paths(&search_path) { + if dir.as_os_str().is_empty() { + continue; + } + for candidate in &names { + let path = dir.join(candidate); + if is_executable(&path) { + return Some(path); + } + } + } + None +} + +/// A `vvaharness` we can run: the profile venv first, then PATH. +pub fn resolve_vvaharness() -> Option { + if let Some(dir) = install_dir() { + let path = venv_vvaharness(&dir); + if is_executable(&path) { + return Some(path); + } + } + find_on_path("vvaharness") +} + +fn run_status(mut command: Command) -> bool { + retry_busy(|| command.status()) + .ok() + .is_some_and(|status| status.success()) +} + +pub fn install_vvah(dir: &Path, python: &Path) -> Result<(), String> { + if std::fs::create_dir_all(dir).is_err() { + return Err(format!("could not create {}", dir.display())); + } + let venv = dir.join("venv"); + if !run_status({ + let mut command = Command::new(python); + command.args(["-m", "venv", "--clear"]).arg(&venv); + command + }) { + return Err("could not create a Python virtual environment for VVAH".to_string()); + } + let pip = venv + .join(venv_bin()) + .join(if cfg!(windows) { "pip.exe" } else { "pip" }); + if !run_status({ + let mut command = Command::new(&pip); + command.args(["install", SOURCE]); + command + }) { + return Err("could not install VVAH into the virtual environment".to_string()); + } + if !is_executable(&venv_vvaharness(dir)) { + return Err("VVAH installed but wally could not find vvaharness in the venv".to_string()); + } + Ok(()) +} + +fn ensure_installed() -> Option { + if let Some(path) = resolve_vvaharness() { + return Some(path); + } + let Some(dir) = install_dir() else { + out::error_line("no user profile directory is available"); + return None; + }; + let python = find_supported_python().map(|(path, _, _)| path); + let shown = python + .as_ref() + .map(PathBuf::as_path) + .unwrap_or(Path::new("python3")); + let command = install_command(&dir, shown); + out::error_line("vvaharness is not installed on this machine"); + out::status_line(&format!("install it with `{command}`")); + if !confirm(&format!("Install it now with `{command}`? [y/N] ")) { + return None; + } + let Some(python) = python.or_else(|| find_supported_python().map(|(path, _, _)| path)) else { + match probe_python() { + Some((major, minor)) if (major, minor) > MAX_PYTHON => { + out::error_line(&too_new_python_message(major, minor)); + } + Some(_) | None => out::error_line(MISSING_PYTHON), + } + return None; + }; + if let Err(error) = install_vvah(&dir, &python) { + out::error_line(&error); + return None; + } + Some(venv_vvaharness(&dir)) +} + +// --------------------------------------------------------------------------- +// Child env and run +// --------------------------------------------------------------------------- + +pub struct ChildEnv { + pub set: Vec<(&'static str, String)>, + pub remove: Vec<&'static str>, +} + +impl ChildEnv { + pub fn for_start(key: &str, base_url: &str) -> Self { + ChildEnv { + set: vec![ + (KEY_VARIABLE, key.to_string()), + (BASE_URL_VARIABLE, base_url.to_string()), + ], + remove: OVERRIDING_VARIABLES.to_vec(), + } + } + + pub fn unchanged() -> Self { + ChildEnv { + set: Vec::new(), + remove: Vec::new(), + } + } + + fn apply(&self, command: &mut Command) { + for name in &self.remove { + command.env_remove(name); + } + for (name, value) in &self.set { + command.env(name, value); + } + } +} + +pub fn run_child(program: &Path, argv: &[String], env: &ChildEnv) -> i32 { + let interrupted = Arc::new(AtomicBool::new(false)); + let noted = Arc::clone(&interrupted); + let _interrupt = crate::util::interrupt::on_interrupt(move || { + noted.store(true, Ordering::SeqCst); + }); + + let mut command = Command::new(program); + command.args(argv); + env.apply(&mut command); + let status = match retry_busy(|| command.status()) { + Ok(status) => status, + Err(error) => { + out::error_line(&format!("could not start VVAH: {error}")); + return 127; + } + }; + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + if interrupted.load(Ordering::SeqCst) { + 130 + } else { + 1 + } +} + +/// `wally vvah [-m ] [--] [] [args...]`. +pub fn launch(requested_model: Option<&str>, args: &[String]) -> i32 { + if let Err(code) = hosted::require_safe_model(requested_model) { + return code; + } + let invocation = match classify(args) { + Ok(invocation) => invocation, + Err(message) => { + out::error_line(&message); + return 2; + } + }; + let cwd = std::env::current_dir().unwrap_or_default(); + let home = std::env::var_os("HOME").map(PathBuf::from); + if refuse_home_workspace(args, &cwd, home.as_deref()) { + out::error_line( + "wally vvah with no extra args scans the current directory. \ + That is your home directory. cd into a repo you are authorised to test, \ + or pass --repo after --.", + ); + return 2; + } + let Some(program) = ensure_installed() else { + return 127; + }; + + let Invocation::Routed { .. } = &invocation else { + if requested_model.is_some() { + out::status_line("-m only applies to scan, remediate and doctor; passing the command through unchanged"); + } + return run_child(&program, &invocation.argv(""), &ChildEnv::unchanged()); + }; + + let console = ConsoleClient::default(); + let Some(hosted) = hosted::hosted_access(&console, "VVAH") else { + return 1; + }; + let (model, base_url) = match hosted::pick_model(&console, &hosted, requested_model) { + Ok(chosen) => chosen, + Err(code) => return code, + }; + + let Some(preferred_dir) = install_dir() else { + out::error_line("no user profile directory is available"); + return 1; + }; + let repo = match &invocation { + Invocation::Routed { rest, .. } => repo_from_rest(rest), + Invocation::Passthrough(_) => ".", + }; + let dir = match config_dir_for_target(preferred_dir, &cwd, repo) { + Ok(dir) => dir, + Err(message) => { + out::error_line(&message); + return 2; + } + }; + let path = match stage_vvah_config(&dir, &program, &model) { + Ok(path) => path, + Err(error) => { + out::error_line(&error); + return 1; + } + }; + + out::status_line(S10_WARNING); + out::status_line(&format!("VVAH will use {model} through {base_url}")); + run_child( + &program, + &invocation.argv(&path.to_string_lossy()), + &ChildEnv::for_start(&hosted.key, &base_url), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + use crate::util::env_lock::lock as env_lock; + + fn strings(tokens: &[&str]) -> Vec { + tokens.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn pinned_version_and_missing_python_message() { + assert_eq!(PINNED_VERSION, "1.5.1"); + assert_eq!( + MISSING_PYTHON, + "python 3.11 is not installed on this machine, Please install it first, Thank You" + ); + } + + #[test] + fn repo_from_rest_reads_flag() { + assert_eq!( + repo_from_rest(&strings(&["--repo", ".", "--remediate"])), + "." + ); + assert_eq!(repo_from_rest(&strings(&["--repo=./x"])), "./x"); + assert_eq!(repo_from_rest(&[]), "."); + } + + #[test] + fn config_dir_for_target_leaves_profile_when_repo_is_elsewhere() { + let dir = tempfile::tempdir().expect("tempdir"); + let cwd = dir.path().join("proj"); + let profile = dir.path().join("profile"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&profile).unwrap(); + let chosen = config_dir_for_target(profile.clone(), &cwd, ".").unwrap(); + assert_eq!(chosen, profile); + } + + #[test] + fn config_dir_for_target_moves_out_when_repo_contains_profile() { + let dir = tempfile::tempdir().expect("tempdir"); + let cwd = dir.path(); + let profile = cwd.join(".config").join("wally"); + std::fs::create_dir_all(&profile).unwrap(); + let chosen = config_dir_for_target(profile, cwd, ".").unwrap(); + assert!(!path_is_inside(&chosen, cwd)); + } + + #[test] + fn packaged_default_yaml_walks_venv_lib() { + let dir = tempfile::tempdir().expect("tempdir"); + let bin = dir.path().join("venv").join("bin"); + let yaml = dir + .path() + .join("venv") + .join("lib") + .join("python3.12") + .join("site-packages") + .join("vvaharness") + .join("config") + .join("profiles") + .join("default.yaml"); + std::fs::create_dir_all(yaml.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(&yaml, "models: {}\n").unwrap(); + let program = bin.join("vvaharness"); + std::fs::write(&program, "").unwrap(); + assert_eq!( + packaged_default_yaml(&program).as_deref(), + Some(yaml.as_path()) + ); + } + + #[test] + fn refuse_home_workspace_only_for_bare_scan() { + let home = Path::new("/Users/me"); + assert!(refuse_home_workspace(&[], home, Some(home))); + assert!(!refuse_home_workspace( + &strings(&["scan", "--repo", "./x"]), + home, + Some(home) + )); + } + + #[test] + fn classify_default_is_routed_scan_with_remediate() { + assert_eq!( + classify(&[]).unwrap(), + Invocation::Routed { + command: "scan".to_string(), + rest: strings(&["--repo", ".", "--remediate"]), + } + ); + } + + #[test] + fn classify_help_passes_through_and_scan_is_routed() { + let help = strings(&["--help"]); + assert_eq!(classify(&help).unwrap(), Invocation::Passthrough(help)); + assert_eq!( + classify(&strings(&["scan", "--repo", "./x"])).unwrap(), + Invocation::Routed { + command: "scan".to_string(), + rest: strings(&["--repo", "./x"]), + } + ); + assert_eq!( + classify(&strings(&["report"])).unwrap(), + Invocation::Passthrough(strings(&["report"])) + ); + } + + #[test] + fn classify_refuses_user_config_on_routed_commands() { + assert!(classify(&strings(&["scan", "--config", "mine.yaml"])).is_err()); + assert!(classify(&strings(&["doctor", "--config=mine.yaml"])).is_err()); + } + + #[test] + fn python_version_parse_and_floor() { + assert_eq!(parse_python_version("Python 3.11.9\n"), Some((3, 11))); + assert!(python_is_new_enough(3, 11)); + assert!(python_is_new_enough(3, 12)); + assert!(!python_is_new_enough(3, 10)); + assert!(python_is_supported(3, 11)); + assert!(python_is_supported(3, 14)); + assert!(!python_is_supported(3, 10)); + assert!(!python_is_supported(3, 15)); + assert!(too_new_python_message(3, 15).contains("3.11–3.14")); + } + + #[test] + fn config_yaml_maps_roles_without_a_key() { + let yaml = config_yaml("glm-5.3-flash"); + assert!(yaml.contains("autoexclude: {id: glm-5.3-flash, via: openai}")); + assert!(yaml.contains("remediate: {id: glm-5.3-flash, via: openai}")); + assert!(yaml.contains(" orchestrator: {id: glm-5.3-flash, via: openai}")); + assert!(yaml.contains("max_turns: 60")); + assert!(!yaml.contains("inject:")); + assert!(!yaml.to_lowercase().contains("api_key")); + assert!(!yaml.contains("sk-")); + } + + #[test] + fn install_command_shows_venv_and_pip_install() { + let dir = tempfile::tempdir().expect("tempdir"); + let command = install_command(dir.path(), Path::new("python3.12")); + assert!(command.contains("python3.12 -m venv")); + assert!(command.contains("pip")); + assert!(command.contains(SOURCE)); + } + + #[test] + fn argv_routed_injects_config_before_rest() { + let routed = Invocation::Routed { + command: "scan".to_string(), + rest: strings(&["--repo", "."]), + }; + assert_eq!( + routed.argv("/tmp/cfg.yaml"), + strings(&["scan", "--config", "/tmp/cfg.yaml", "--repo", "."]) + ); + } + + #[cfg(unix)] + #[test] + fn write_private_file_is_owner_only_and_contains_no_key() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.local.yaml"); + let yaml = config_yaml("glm-5.3-flash"); + write_private_file(&path, &yaml).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let text = fs::read_to_string(&path).unwrap(); + assert_eq!(text, yaml); + assert!(!text.to_lowercase().contains("api_key")); + assert!(!text.contains("sk-")); + } + + #[test] + fn child_env_sets_openai_vars_only_on_child() { + let env = ChildEnv::for_start("key", "https://api.example/v1"); + assert_eq!( + env.set, + vec![ + ("OPENAI_API_KEY", "key".to_string()), + ("OPENAI_BASE_URL", "https://api.example/v1".to_string()), + ] + ); + assert!(env.remove.contains(&CLOUD_KEY_VARIABLE)); + } + + #[cfg(unix)] + #[test] + fn run_child_applies_openai_env_on_child_only() { + use std::os::unix::fs::PermissionsExt; + + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("tempdir"); + let stub = dir.path().join("vvah-stub"); + fs::write( + &stub, + "#!/bin/sh\nif test \"$OPENAI_API_KEY\" = child && test \"$OPENAI_BASE_URL\" = https://x/v1 && test -z \"$WALLY_CLOUD_KEY\"; then exit 43; fi\nexit 9\n", + ) + .unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o700)).unwrap(); + + let previous = std::env::var("WALLY_CLOUD_KEY").ok(); + std::env::set_var("WALLY_CLOUD_KEY", "parent"); + let code = run_child( + &stub, + &strings(&["scan", "--config", "c.yaml"]), + &ChildEnv::for_start("child", "https://x/v1"), + ); + let remaining = std::env::var("WALLY_CLOUD_KEY"); + match previous { + Some(value) => std::env::set_var("WALLY_CLOUD_KEY", value), + None => std::env::remove_var("WALLY_CLOUD_KEY"), + } + assert_eq!(code, 43); + assert_eq!(remaining.as_deref(), Ok("parent")); + } +} diff --git a/tests/golden/README.md b/tests/golden/README.md index ae88c310..db67d50b 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -116,3 +116,8 @@ each. `help__command-code` and `help__help__command-code` are new cases. `wally shannon` added a line to every command list, so the same 31 cases were re-captured from the Rust binary; that line is the only difference in each. `help__shannon` and `help__help__shannon` are new cases. + +`wally deepsec` and `wally vvah` added a **Security tools** group to every +command list, so those two lines are the only extra difference in each. +`help__deepsec`, `help__help__deepsec`, `help__vvah` and `help__help__vvah` are +new cases. diff --git a/tests/golden/cases.json b/tests/golden/cases.json index de1d0c82..55c13b24 100644 --- a/tests/golden/cases.json +++ b/tests/golden/cases.json @@ -306,6 +306,20 @@ "shannon" ] }, + { + "name": "help__deepsec", + "args": [ + "help", + "deepsec" + ] + }, + { + "name": "help__vvah", + "args": [ + "help", + "vvah" + ] + }, { "name": "help__codex", "args": [ @@ -497,6 +511,22 @@ "--help" ] }, + { + "name": "help__help__deepsec", + "args": [ + "help", + "deepsec", + "--help" + ] + }, + { + "name": "help__help__vvah", + "args": [ + "help", + "vvah", + "--help" + ] + }, { "name": "help__help__codex", "args": [ diff --git a/tests/golden/expected/alt_help__--no-color__--help.stdout b/tests/golden/expected/alt_help__--no-color__--help.stdout index 221faa58..f4775c3c 100644 --- a/tests/golden/expected/alt_help__--no-color__--help.stdout +++ b/tests/golden/expected/alt_help__--no-color__--help.stdout @@ -27,6 +27,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/alt_help__-h.stdout b/tests/golden/expected/alt_help__-h.stdout index 221faa58..f4775c3c 100644 --- a/tests/golden/expected/alt_help__-h.stdout +++ b/tests/golden/expected/alt_help__-h.stdout @@ -27,6 +27,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/alt_help__help.stdout b/tests/golden/expected/alt_help__help.stdout index 221faa58..f4775c3c 100644 --- a/tests/golden/expected/alt_help__help.stdout +++ b/tests/golden/expected/alt_help__help.stdout @@ -27,6 +27,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/alt_help__help__nope.stdout b/tests/golden/expected/alt_help__help__nope.stdout index 221faa58..f4775c3c 100644 --- a/tests/golden/expected/alt_help__help__nope.stdout +++ b/tests/golden/expected/alt_help__help__nope.stdout @@ -27,6 +27,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/help__ROOT.stdout b/tests/golden/expected/help__ROOT.stdout index 221faa58..f4775c3c 100644 --- a/tests/golden/expected/help__ROOT.stdout +++ b/tests/golden/expected/help__ROOT.stdout @@ -27,6 +27,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/help__deepsec.code b/tests/golden/expected/help__deepsec.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__deepsec.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__deepsec.stderr b/tests/golden/expected/help__deepsec.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__deepsec.stdout b/tests/golden/expected/help__deepsec.stdout new file mode 100644 index 00000000..1bcf4379 --- /dev/null +++ b/tests/golden/expected/help__deepsec.stdout @@ -0,0 +1,21 @@ +Scan your own repos with DeepSec + +Usage: wally deepsec [OPTIONS] [args...] + +Arguments: + args DeepSec's own command and options; with none, init + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Start a scan on a repo you are authorised to test (runs init) + wally deepsec -- init --repo ./my-repo + + # Choose the model + wally deepsec -m glm-5.3-flash -- process + + # Other DeepSec commands pass through + wally deepsec -- status diff --git a/tests/golden/expected/help__help.stdout b/tests/golden/expected/help__help.stdout index 221faa58..f4775c3c 100644 --- a/tests/golden/expected/help__help.stdout +++ b/tests/golden/expected/help__help.stdout @@ -27,6 +27,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/help__help__deepsec.code b/tests/golden/expected/help__help__deepsec.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__help__deepsec.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__help__deepsec.stderr b/tests/golden/expected/help__help__deepsec.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__help__deepsec.stdout b/tests/golden/expected/help__help__deepsec.stdout new file mode 100644 index 00000000..1bcf4379 --- /dev/null +++ b/tests/golden/expected/help__help__deepsec.stdout @@ -0,0 +1,21 @@ +Scan your own repos with DeepSec + +Usage: wally deepsec [OPTIONS] [args...] + +Arguments: + args DeepSec's own command and options; with none, init + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Start a scan on a repo you are authorised to test (runs init) + wally deepsec -- init --repo ./my-repo + + # Choose the model + wally deepsec -m glm-5.3-flash -- process + + # Other DeepSec commands pass through + wally deepsec -- status diff --git a/tests/golden/expected/help__help__vvah.code b/tests/golden/expected/help__help__vvah.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__help__vvah.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__help__vvah.stderr b/tests/golden/expected/help__help__vvah.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__help__vvah.stdout b/tests/golden/expected/help__help__vvah.stdout new file mode 100644 index 00000000..b988c8f7 --- /dev/null +++ b/tests/golden/expected/help__help__vvah.stdout @@ -0,0 +1,22 @@ +Scan your own repos with VVAH + +Usage: wally vvah [OPTIONS] [args...] + +Arguments: + args VVAH's own command and options; with none, scan + --repo . --remediate + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Scan the current repo you are authorised to test + wally vvah + + # Scan another path with a chosen model + wally vvah -m glm-5.3-flash -- scan --repo ./my-repo + + # Other VVAH commands pass through + wally vvah -- report diff --git a/tests/golden/expected/help__vvah.code b/tests/golden/expected/help__vvah.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__vvah.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__vvah.stderr b/tests/golden/expected/help__vvah.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__vvah.stdout b/tests/golden/expected/help__vvah.stdout new file mode 100644 index 00000000..b988c8f7 --- /dev/null +++ b/tests/golden/expected/help__vvah.stdout @@ -0,0 +1,22 @@ +Scan your own repos with VVAH + +Usage: wally vvah [OPTIONS] [args...] + +Arguments: + args VVAH's own command and options; with none, scan + --repo . --remediate + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Scan the current repo you are authorised to test + wally vvah + + # Scan another path with a chosen model + wally vvah -m glm-5.3-flash -- scan --repo ./my-repo + + # Other VVAH commands pass through + wally vvah -- report diff --git a/tests/golden/expected/parse__--bogus.stderr b/tests/golden/expected/parse__--bogus.stderr index 3b2a0051..97285fb6 100644 --- a/tests/golden/expected/parse__--bogus.stderr +++ b/tests/golden/expected/parse__--bogus.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/parse__-U__--json.stderr b/tests/golden/expected/parse__-U__--json.stderr index b074f350..96fb9b96 100644 --- a/tests/golden/expected/parse__-U__--json.stderr +++ b/tests/golden/expected/parse__-U__--json.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/parse__-u__-v.stderr b/tests/golden/expected/parse__-u__-v.stderr index ff30d7b7..84ddc118 100644 --- a/tests/golden/expected/parse__-u__-v.stderr +++ b/tests/golden/expected/parse__-u__-v.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/parse__-z.stderr b/tests/golden/expected/parse__-z.stderr index 0c41061e..09171f7b 100644 --- a/tests/golden/expected/parse__-z.stderr +++ b/tests/golden/expected/parse__-z.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__auth.stderr b/tests/golden/expected/unknown__auth.stderr index dfeefdf5..6a3c13c6 100644 --- a/tests/golden/expected/unknown__auth.stderr +++ b/tests/golden/expected/unknown__auth.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__chat.stderr b/tests/golden/expected/unknown__chat.stderr index 120fffc8..4a1a8bdc 100644 --- a/tests/golden/expected/unknown__chat.stderr +++ b/tests/golden/expected/unknown__chat.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__diarize.stderr b/tests/golden/expected/unknown__diarize.stderr index 54374005..c5186bbb 100644 --- a/tests/golden/expected/unknown__diarize.stderr +++ b/tests/golden/expected/unknown__diarize.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__embed.stderr b/tests/golden/expected/unknown__embed.stderr index a84dbbfc..764114d4 100644 --- a/tests/golden/expected/unknown__embed.stderr +++ b/tests/golden/expected/unknown__embed.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__foo.stderr b/tests/golden/expected/unknown__foo.stderr index e30aa599..a5dcde70 100644 --- a/tests/golden/expected/unknown__foo.stderr +++ b/tests/golden/expected/unknown__foo.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__image.stderr b/tests/golden/expected/unknown__image.stderr index 9d5c0b8d..c44e9332 100644 --- a/tests/golden/expected/unknown__image.stderr +++ b/tests/golden/expected/unknown__image.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__list.stderr b/tests/golden/expected/unknown__list.stderr index 6605d1bd..90dfb4af 100644 --- a/tests/golden/expected/unknown__list.stderr +++ b/tests/golden/expected/unknown__list.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__lora.stderr b/tests/golden/expected/unknown__lora.stderr index 2391914d..99518b27 100644 --- a/tests/golden/expected/unknown__lora.stderr +++ b/tests/golden/expected/unknown__lora.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__ls.stderr b/tests/golden/expected/unknown__ls.stderr index bfaf1a29..65ca1f3c 100644 --- a/tests/golden/expected/unknown__ls.stderr +++ b/tests/golden/expected/unknown__ls.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__pull.stderr b/tests/golden/expected/unknown__pull.stderr index 3b8042ca..d9064832 100644 --- a/tests/golden/expected/unknown__pull.stderr +++ b/tests/golden/expected/unknown__pull.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__rag.stderr b/tests/golden/expected/unknown__rag.stderr index 7ea8a6c3..755bcbbf 100644 --- a/tests/golden/expected/unknown__rag.stderr +++ b/tests/golden/expected/unknown__rag.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__rerank.stderr b/tests/golden/expected/unknown__rerank.stderr index f431fd95..3c0f04a4 100644 --- a/tests/golden/expected/unknown__rerank.stderr +++ b/tests/golden/expected/unknown__rerank.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__rm.stderr b/tests/golden/expected/unknown__rm.stderr index b181bd18..f61aeb4a 100644 --- a/tests/golden/expected/unknown__rm.stderr +++ b/tests/golden/expected/unknown__rm.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__segment.stderr b/tests/golden/expected/unknown__segment.stderr index 6bc75cbd..81ebc7c3 100644 --- a/tests/golden/expected/unknown__segment.stderr +++ b/tests/golden/expected/unknown__segment.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__show.stderr b/tests/golden/expected/unknown__show.stderr index 3cbf7c7f..06b9ba46 100644 --- a/tests/golden/expected/unknown__show.stderr +++ b/tests/golden/expected/unknown__show.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__stt.stderr b/tests/golden/expected/unknown__stt.stderr index 1eb07ff4..a7c314d5 100644 --- a/tests/golden/expected/unknown__stt.stderr +++ b/tests/golden/expected/unknown__stt.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__tts.stderr b/tests/golden/expected/unknown__tts.stderr index af70b86c..1b44a057 100644 --- a/tests/golden/expected/unknown__tts.stderr +++ b/tests/golden/expected/unknown__tts.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__vad.stderr b/tests/golden/expected/unknown__vad.stderr index 254956a7..7c2f3274 100644 --- a/tests/golden/expected/unknown__vad.stderr +++ b/tests/golden/expected/unknown__vad.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__vlm.stderr b/tests/golden/expected/unknown__vlm.stderr index 96ff1dbb..fbf9d7dd 100644 --- a/tests/golden/expected/unknown__vlm.stderr +++ b/tests/golden/expected/unknown__vlm.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__voice.stderr b/tests/golden/expected/unknown__voice.stderr index d6bbb453..847768c0 100644 --- a/tests/golden/expected/unknown__voice.stderr +++ b/tests/golden/expected/unknown__voice.stderr @@ -28,6 +28,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/version__bare.stderr b/tests/golden/expected/version__bare.stderr index 6a800ada..b31e25b9 100644 --- a/tests/golden/expected/version__bare.stderr +++ b/tests/golden/expected/version__bare.stderr @@ -27,6 +27,10 @@ Coding tools: prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session