use std::{
env, fs,
io::IsTerminal,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use dirs;
use serde::Serialize;
use serde_json::Value;
use crate::{
agents, anomaly,
approvals::{self, ApprovalDecision, ApprovalScope},
broker, config, context, detection, env_file, git_context, grants,
logs::{self as audit_logs, self as logs, LogKind},
modes, pending_requests,
policy::{self, AccessRequest, ApprovalMode},
recovery, registry,
runner::{self, RunCommandRequest},
term, unlock, vault, worktrees,
};
#[derive(Debug)]
pub struct ChildExit {
code: i32,
}
impl ChildExit {
pub fn new(code: i32) -> Self {
Self { code }
}
pub fn exit_code(&self) -> u8 {
u8::try_from(self.code).unwrap_or(1)
}
}
impl std::fmt::Display for ChildExit {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "child process exited with {}", self.code)
}
}
impl std::error::Error for ChildExit {}
#[derive(Debug, Parser)]
#[command(
name = "ward",
version,
about = "AI secret firewall for local development"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Setup {
#[arg(long)]
yes: bool,
#[arg(long)]
project: Option<String>,
#[arg(long, default_value = ".env")]
source: PathBuf,
#[arg(long, default_value = config::DEFAULT_VAULT_FILE)]
vault: PathBuf,
#[arg(long)]
commit_vault: bool,
#[arg(long)]
ignore_vault: bool,
#[arg(long)]
remove_plaintext: bool,
#[arg(long)]
keep_plaintext: bool,
#[arg(long, default_value = "8h")]
unlock_ttl: String,
#[arg(long)]
no_unlock: bool,
},
Init {
#[arg(long)]
project: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
bare: bool,
},
Import {
source: PathBuf,
#[arg(long)]
vault: Option<PathBuf>,
},
Register {
project: String,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
vault: Option<PathBuf>,
},
Use { project: String },
Projects {
#[command(subcommand)]
command: ProjectsCommand,
},
Env {
#[command(subcommand)]
command: EnvCommand,
},
Request {
#[arg(long)]
profile: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
agent_key_id: Option<String>,
#[arg(long)]
worktree: Option<PathBuf>,
#[arg(long)]
git_remote: Option<String>,
#[arg(long)]
commit: Option<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
action: Option<String>,
#[arg(long)]
command: Option<String>,
#[arg(long = "env")]
env_names: Vec<String>,
#[arg(long)]
json: bool,
#[arg(long)]
no_prompt: bool,
},
Allow {
#[arg(long)]
profile: Option<String>,
#[arg(long, value_enum)]
scope: Option<ApprovalScope>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
command: Option<String>,
#[arg(long = "env")]
env_names: Vec<String>,
},
Grants {
#[command(subcommand)]
command: GrantsCommand,
},
Approve {
request_id: uuid::Uuid,
#[arg(long, value_enum)]
scope: ApprovalScope,
#[arg(long)]
confirm_critical: bool,
#[arg(long)]
agent_mediated: bool,
#[arg(long)]
json: bool,
},
Deny {
request_id: uuid::Uuid,
#[arg(long)]
agent_mediated: bool,
#[arg(long)]
json: bool,
},
Run {
#[arg(long)]
profile: Option<String>,
#[arg(long)]
project: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
agent_key_id: Option<String>,
#[arg(long)]
worktree: Option<PathBuf>,
#[arg(long)]
git_remote: Option<String>,
#[arg(long)]
commit: Option<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
action: Option<String>,
#[arg(long = "env")]
env_names: Vec<String>,
#[arg(long)]
json: bool,
#[arg(long)]
no_prompt: bool,
#[arg(
last = true,
help = "Child command and args after --. Put all Ward flags before --."
)]
command: Vec<String>,
},
Dev {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
agent_key_id: Option<String>,
#[arg(long)]
worktree: Option<PathBuf>,
#[arg(long)]
git_remote: Option<String>,
#[arg(long)]
commit: Option<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
no_prompt: bool,
},
Migrate {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
agent_key_id: Option<String>,
#[arg(long)]
worktree: Option<PathBuf>,
#[arg(long)]
git_remote: Option<String>,
#[arg(long)]
commit: Option<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
no_prompt: bool,
},
Doctor,
Broker {
#[command(subcommand)]
command: BrokerCommand,
},
Worktrees {
#[command(subcommand)]
command: WorktreesCommand,
},
Logs {
#[command(subcommand)]
command: Option<LogsCommand>,
#[arg(value_enum)]
kind: Option<LogKind>,
},
Edit,
Unlock {
#[arg(long, default_value = "8h")]
ttl: String,
#[arg(long)]
mode: Option<String>,
},
Modes {
#[command(subcommand)]
command: ModesCommand,
},
Lock,
Teardown {
#[arg(long)]
project: Option<String>,
#[arg(long = "export", default_value = ".env.export")]
export_path: PathBuf,
#[arg(long)]
yes: bool,
#[arg(long)]
restore_env: bool,
},
#[cfg(all(coverage, not(test)))]
#[command(hide = true, name = "__coverage")]
Coverage,
#[command(hide = true, name = "__broker")]
BrokerServe,
ShellInit {
#[arg(long)]
shell: Option<String>,
},
Human {
#[arg(long, default_value = "8h")]
ttl: String,
},
Rotate {
#[arg(long)]
project: Option<String>,
},
Recovery {
#[command(subcommand)]
command: RecoveryCommand,
},
Dashboard {
#[command(subcommand)]
command: Option<DashboardCommand>,
},
#[command(hide = true, name = "__dashboard-server")]
DashboardServer {
#[arg(long)]
port: u16,
#[arg(long)]
token: String,
},
#[command(hide = true, name = "__human-guardian")]
HumanGuardian {
#[arg(long)]
shell_pid: u32,
#[arg(long)]
session_token: String,
#[arg(long)]
ttl_seconds: i64,
},
}
#[derive(Debug, Subcommand)]
pub enum ProjectsCommand {
List,
Show { project: Option<String> },
Register {
project: String,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
vault: Option<PathBuf>,
},
Use { project: String },
Remove { project: String },
}
#[derive(Debug, Subcommand)]
pub enum DashboardCommand {
Start {
#[arg(long)]
port: Option<u16>,
#[arg(long)]
no_open: bool,
#[arg(long)]
foreground: bool,
#[arg(long)]
json: bool,
},
Stop {
#[arg(long)]
all: bool,
#[arg(long)]
pid: Option<u32>,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
json: bool,
},
Status {
#[arg(long)]
json: bool,
},
Tui,
}
#[derive(Debug, Subcommand)]
pub enum BrokerCommand {
Status,
Stop,
SocketPath,
}
#[derive(Debug, Subcommand)]
pub enum WorktreesCommand {
List {
#[arg(long)]
project: String,
},
AllowRoot {
#[arg(long)]
project: String,
path: PathBuf,
},
RemoveRoot {
#[arg(long)]
project: String,
path: PathBuf,
},
Approve {
request_id: uuid::Uuid,
#[arg(long)]
json: bool,
},
Deny {
request_id: uuid::Uuid,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum EnvCommand {
List {
#[arg(long)]
project: Option<String>,
},
Set {
#[arg(long)]
project: Option<String>,
assignment: String,
},
Unset {
#[arg(long)]
project: Option<String>,
key: String,
},
Unlock {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value = ".env")]
output: PathBuf,
#[arg(long)]
force: bool,
},
Lock {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value = ".env")]
source: PathBuf,
},
Export {
#[arg(long)]
project: Option<String>,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
force: bool,
#[arg(long)]
unsafe_stdout: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum GrantsCommand {
List,
Revoke { grant_id: uuid::Uuid },
Prune,
}
#[derive(Debug, Subcommand)]
pub enum LogsCommand {
View {
#[arg(value_enum)]
kind: LogKind,
},
Verify {
#[arg(value_enum)]
kind: Option<LogKind>,
#[arg(long)]
full: bool,
},
Export {
#[arg(value_enum)]
kind: LogKind,
#[arg(long)]
output: PathBuf,
#[arg(long)]
force: bool,
},
Unlock {
#[arg(long, default_value = "15m")]
ttl: String,
},
}
#[derive(Debug, Subcommand)]
pub enum ModesCommand {
List,
Push {
#[arg(long)]
global: bool,
#[arg(long)]
project: Option<String>,
},
Status,
}
#[derive(Debug, Subcommand)]
pub enum RecoveryCommand {
Export {
#[arg(long)]
output: Option<PathBuf>,
},
Import { path: Option<PathBuf> },
Create,
Restore {
path: Option<PathBuf>,
},
}
pub fn dispatch(cli: Cli) -> Result<()> {
match cli.command {
Commands::Setup {
yes,
project,
source,
vault,
commit_vault,
ignore_vault,
remove_plaintext,
keep_plaintext,
unlock_ttl,
no_unlock,
} => setup(SetupOptions {
yes,
project,
source,
vault,
commit_vault,
ignore_vault,
remove_plaintext,
keep_plaintext,
unlock_ttl,
no_unlock,
}),
Commands::Init {
project,
force,
bare,
} => init(project, force, bare),
Commands::Import { source, vault } => import(source, vault),
Commands::Register {
project,
path,
vault,
} => register(project, path, vault),
Commands::Use { project } => use_project(&project),
Commands::Projects { command } => projects_command(command),
Commands::Env { command } => env_command(command),
Commands::Request {
profile,
agent,
agent_key_id,
worktree,
git_remote,
commit,
branch,
action,
command,
env_names,
json,
no_prompt,
} => request(
profile,
AgentContextOptions {
agent,
agent_key_id,
worktree,
git_remote,
commit,
branch,
},
action,
command,
env_names,
json,
no_prompt,
),
Commands::Allow {
profile,
scope,
agent,
branch,
command,
env_names,
} => allow(profile, scope, agent, branch, command, env_names),
Commands::Grants { command } => grants_command(command),
Commands::Approve {
request_id,
scope,
confirm_critical,
agent_mediated,
json,
} => approve(request_id, scope, confirm_critical, agent_mediated, json),
Commands::Deny {
request_id,
agent_mediated,
json,
} => deny(request_id, agent_mediated, json),
Commands::Run {
profile,
project,
agent,
agent_key_id,
worktree,
git_remote,
commit,
branch,
action,
env_names,
json,
no_prompt,
command,
} => run_with_context(
RunOptions {
profile,
project,
agent: agent.clone(),
branch: branch.clone(),
action,
env_names,
command,
json,
no_prompt,
},
AgentContextOptions {
agent,
agent_key_id,
worktree,
git_remote,
commit,
branch,
},
),
Commands::Dev {
agent,
agent_key_id,
worktree,
git_remote,
commit,
branch,
json,
no_prompt,
} => run_with_context(
RunOptions {
profile: Some("dev".to_string()),
project: None,
agent: agent.clone(),
branch: branch.clone(),
action: None,
env_names: Vec::new(),
command: Vec::new(),
json,
no_prompt,
},
AgentContextOptions {
agent,
agent_key_id,
worktree,
git_remote,
commit,
branch,
},
),
Commands::Migrate {
agent,
agent_key_id,
worktree,
git_remote,
commit,
branch,
json,
no_prompt,
} => run_with_context(
RunOptions {
profile: Some("migrate".to_string()),
project: None,
agent: agent.clone(),
branch: branch.clone(),
action: None,
env_names: Vec::new(),
command: Vec::new(),
json,
no_prompt,
},
AgentContextOptions {
agent,
agent_key_id,
worktree,
git_remote,
commit,
branch,
},
),
Commands::Doctor => doctor(),
Commands::Broker { command } => broker_command(command),
Commands::Worktrees { command } => worktrees_command(command),
Commands::Logs { command, kind } => logs(command, kind),
Commands::Edit => edit(),
Commands::Unlock { ttl, mode } => unlock_vault(&ttl, mode.as_deref()),
Commands::Modes { command } => modes_command(command),
Commands::Lock => lock(),
Commands::Teardown {
project,
export_path,
yes,
restore_env,
} => teardown(project, export_path, yes, restore_env),
#[cfg(all(coverage, not(test)))]
Commands::Coverage => coverage_exercise_cli_edges(),
Commands::BrokerServe => broker::serve(),
Commands::ShellInit { shell } => shell_init(shell.as_deref()),
Commands::Rotate { project } => rotate_vault(project.as_deref()),
Commands::Recovery { command } => recovery_command(command),
Commands::Dashboard { command } => dashboard_command(command),
Commands::DashboardServer { port, token } => crate::webui::serve_standalone(port, token),
Commands::Human { ttl } => crate::human::activate_human_mode(&ttl),
Commands::HumanGuardian {
shell_pid,
session_token,
ttl_seconds,
} => crate::human::serve_guardian(shell_pid, &session_token, ttl_seconds),
}
}
#[derive(Serialize)]
struct VaultImportEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
project: &'a str,
source: &'a Path,
vault: &'a Path,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct EnvFileEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
project: &'a str,
vault: &'a Path,
#[serde(skip_serializing_if = "Option::is_none")]
env_file: Option<&'a Path>,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<&'a str>,
}
#[derive(Serialize)]
struct RequestEvent<'a> {
access: &'a AccessRequest,
policy: &'a policy::PolicyEvaluation,
git: &'a git_context::GitContext,
#[serde(skip_serializing_if = "Option::is_none")]
verified_context: Option<&'a context::VerifiedContext>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ApprovalEvent<'a> {
decision: &'a ApprovalDecision,
persisted_grant: Option<uuid::Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
approval_receipt_hash: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
signer_key_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
signature_algorithm: Option<&'a str>,
critical_confirmation: bool,
#[serde(skip_serializing_if = "Option::is_none")]
human_proof: Option<&'static str>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ExecutionStartedEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
project: &'a str,
agent: &'a Option<String>,
branch: &'a Option<String>,
declared_action: &'a Option<String>,
requested_command: &'a str,
cwd: &'a Path,
git: &'a git_context::GitContext,
requested_env: &'a [String],
injected_env: &'a [String],
policy_findings: &'a [detection::Finding],
approval_scope: ApprovalScope,
approval_source: approvals::ApprovalSource,
grant_id: Option<uuid::Uuid>,
approval_receipt_hash: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
agent_key_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
verified_context: Option<&'a context::VerifiedContext>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ExecutionEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
project: &'a str,
agent: &'a Option<String>,
branch: &'a Option<String>,
declared_action: &'a Option<String>,
requested_command: &'a str,
cwd: &'a Path,
git: &'a git_context::GitContext,
requested_env: &'a [String],
injected_env: &'a [String],
policy_findings: &'a [detection::Finding],
approval_scope: ApprovalScope,
approval_source: approvals::ApprovalSource,
grant_id: Option<uuid::Uuid>,
approval_receipt_hash: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
agent_key_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
verified_context: Option<&'a context::VerifiedContext>,
outcome: &'a runner::RunCommandOutcome,
}
#[derive(Serialize)]
struct OutputRedactionEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
command: &'a str,
count: usize,
alerts: &'a [runner::OutputAlert],
}
#[derive(Serialize)]
struct VaultEditEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
project: &'a str,
vault: &'a Path,
}
#[derive(Serialize)]
struct VaultUnlockEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
status: &'a str,
project: &'a str,
vault: &'a Path,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultLockEvent {
#[serde(rename = "type")]
event_type: &'static str,
revoked_session_grants: usize,
cleared_unlock_sessions: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LogsUnlockEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
project: &'a str,
vault: &'a Path,
expires_at: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct TeardownEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
project: &'a str,
export_path: &'a Path,
removed_files: Vec<String>,
removed_grants: usize,
removed_pending_requests: usize,
cleared_unlock_sessions: usize,
}
struct SetupOptions {
yes: bool,
project: Option<String>,
source: PathBuf,
vault: PathBuf,
commit_vault: bool,
ignore_vault: bool,
remove_plaintext: bool,
keep_plaintext: bool,
unlock_ttl: String,
no_unlock: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SetupEvent<'a> {
#[serde(rename = "type")]
event_type: &'static str,
project: &'a str,
source: &'a Path,
vault: &'a Path,
imported: bool,
removed_plaintext: bool,
locked_env: bool,
committed_vault: bool,
unlock_created: bool,
unlock_expires_at: Option<String>,
}
struct RunOptions {
profile: Option<String>,
project: Option<String>,
agent: Option<String>,
branch: Option<String>,
action: Option<String>,
env_names: Vec<String>,
command: Vec<String>,
json: bool,
no_prompt: bool,
}
#[derive(Debug, Clone, Default)]
struct AgentContextOptions {
agent: Option<String>,
agent_key_id: Option<String>,
worktree: Option<PathBuf>,
git_remote: Option<String>,
commit: Option<String>,
branch: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RunApprovalRequiredResponse<'a> {
status: &'static str,
unlock_required: bool,
#[serde(flatten)]
request: pending_requests::PendingRequestResponse<'a>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RunUnlockRequiredResponse<'a> {
status: &'static str,
approval_required: bool,
unlock_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
unlock_reason: Option<&'a str>,
project: &'a str,
command: &'a str,
env: &'a [String],
findings: &'a [detection::Finding],
risk: String,
unlock_command: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RunDeniedResponse<'a> {
status: &'static str,
approval_required: bool,
unlock_required: bool,
project: &'a str,
command: &'a str,
env: &'a [String],
findings: &'a [detection::Finding],
risk: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RunVaultKeyMissingResponse<'a> {
status: &'static str,
approval_required: bool,
unlock_required: bool,
project: &'a str,
command: &'a str,
env: &'a [String],
missing_env: Vec<String>,
findings: &'a [detection::Finding],
risk: String,
message: &'static str,
remediation: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InvalidInvocationResponse {
status: &'static str,
reason: &'static str,
message: &'static str,
correct_example: &'static str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WorktreeRequiredResponse<'a> {
status: &'static str,
project: &'a str,
worktree: &'a Path,
git_remote: &'a str,
branch: &'a str,
commit: &'a str,
reason: &'a str,
approve_command: String,
deny_command: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WorktreeBoundResponse<'a> {
status: &'static str,
project: &'a str,
worktree: &'a Path,
match_kind: &'a str,
continued: bool,
}
#[derive(Debug, Clone)]
struct ResolvedProfile {
command: String,
command_args: Vec<String>,
env_names: Vec<String>,
action: Option<String>,
default_scope: ApprovalScope,
}
fn setup(options: SetupOptions) -> Result<()> {
if options.commit_vault && options.ignore_vault {
anyhow::bail!("choose either --commit-vault or --ignore-vault");
}
if options.remove_plaintext && options.keep_plaintext {
anyhow::bail!("choose either --remove-plaintext or --keep-plaintext");
}
if !options.no_unlock {
unlock::parse_ttl(&options.unlock_ttl)?;
}
let cwd = env::current_dir()?;
let commit_vault = !options.ignore_vault;
let remove_plaintext = options.remove_plaintext && !options.keep_plaintext;
let source_exists = options.source.exists();
let configured_vault_path = if options.vault.is_absolute() {
options.vault.clone()
} else {
cwd.join(&options.vault)
};
let registered_vault_path = config::read_project_config(&cwd)
.ok()
.and_then(|existing| registry::resolve_project(Some(&existing.project), &cwd).ok())
.and_then(|resolved| {
let same_path = resolved.path == cwd
|| resolved.path.canonicalize().ok() == cwd.canonicalize().ok();
same_path.then_some(resolved.vault)
})
.filter(|path| path.exists());
let vault_path = registered_vault_path.unwrap_or(configured_vault_path);
let source_is_locked = if source_exists {
env_file::is_locked_env_file(&options.source)?
} else {
false
};
let env_keys = if source_exists && !source_is_locked {
config::env_keys_from_dotenv_file(&options.source)?
} else if let Ok(existing) = config::read_project_config(&cwd) {
let mut keys = std::collections::BTreeSet::new();
for profile in existing.profiles.values() {
keys.extend(profile.env.iter().cloned());
}
keys.into_iter().collect::<Vec<_>>()
} else {
Vec::new()
};
let mut project_config = match config::read_project_config(&cwd) {
Ok(mut existing) => {
existing.project = options.project.unwrap_or(existing.project);
existing.vault = options.vault.clone();
config::merge_default_profiles(&mut existing, &env_keys, &cwd);
existing
}
Err(_) => {
let mut created = config::ProjectConfig::default_for_dir(&cwd, options.project)?;
created.vault = options.vault.clone();
created.profiles = config::default_profiles(&env_keys, &cwd);
created
}
};
config::merge_default_profiles(&mut project_config, &env_keys, &cwd);
config::write_project_config(&cwd, &project_config, true)?;
let env_example = config::ensure_env_example(&cwd)?;
let agent_instructions = config::ensure_agent_instructions(&cwd, &project_config.project)?;
config::ensure_gitignore(&cwd, commit_vault)?;
term::header(&project_config.project);
let mut imported = false;
let mut locked_env = false;
let mut setup_passphrase = None;
let mut verified_env_keys = None;
let mut recovery_plaintext = None;
if source_exists {
if source_is_locked {
if !vault_path.exists() {
anyhow::bail!(
"{} is an Ward locked marker but {} is missing; restore a plaintext dotenv file or the vault before setup",
options.source.display(),
vault_path.display()
);
}
env_file::lock_env_file(&options.source, &vault_path)?;
locked_env = true;
} else {
let passphrase = vault::read_new_passphrase()?;
term::blank();
let sp = term::spinner("Encrypting vault");
vault::import_env_file(&options.source, &vault_path, &passphrase)?;
let plaintext = vault::decrypt_vault_file(&vault_path, &passphrase)?;
verified_env_keys = Some(config::env_keys_from_dotenv_str(&plaintext)?);
recovery_plaintext = Some(plaintext);
setup_passphrase = Some(passphrase);
imported = true;
if !options.keep_plaintext && !remove_plaintext {
env_file::lock_env_file(&options.source, &vault_path)?;
locked_env = true;
}
term::done(sp, "Vault encrypted");
}
} else if !vault_path.exists() {
let passphrase = vault::read_new_passphrase()?;
term::blank();
let sp = term::spinner("Creating empty vault");
let envelope = vault::encrypt_env("", &passphrase)?;
vault::write_vault(&vault_path, &envelope)?;
vault::decrypt_vault_file(&vault_path, &passphrase)?;
env_file::lock_env_file(&options.source, &vault_path)?;
verified_env_keys = Some(Vec::new());
recovery_plaintext = Some(String::new());
setup_passphrase = Some(passphrase);
locked_env = true;
term::done(sp, "Empty vault encrypted");
}
if let Some(env_keys) = verified_env_keys.as_deref() {
config::replace_default_profiles(&mut project_config, env_keys, &cwd);
config::write_project_config(&cwd, &project_config, true)?;
}
registry::update_project_vault(&project_config.project, cwd.clone(), vault_path.clone())?;
term::ok(".gitignore updated");
if env_example.is_some() {
term::ok(".env.example created");
}
if agent_instructions.is_some() {
term::ok("AGENTS.md written");
}
let mut removed_plaintext = false;
if source_exists && !source_is_locked && remove_plaintext {
fs::remove_file(&options.source)
.context(format!("failed to remove {}", options.source.display()))?;
removed_plaintext = true;
}
let setup_passphrase_final: Option<String> = if options.no_unlock {
setup_passphrase
} else {
Some(match setup_passphrase {
Some(p) => p,
None => vault::read_existing_passphrase()?,
})
};
if recovery_plaintext.is_none() {
if let Some(passphrase) = setup_passphrase_final.as_deref() {
recovery_plaintext = vault::decrypt_vault_file(&vault_path, passphrase).ok();
}
}
let unlock_session = if options.no_unlock {
None
} else {
let passphrase = setup_passphrase_final.as_deref().unwrap();
let sp = term::spinner("Starting broker session");
match create_run_unlock_session(
&project_config.project,
&vault_path,
passphrase,
&options.unlock_ttl,
None,
) {
Ok(session) => {
let expires = session.expires_at.format("%H:%M").to_string();
term::done(sp, &format!("Session unlocked · expires {}", expires));
Some(session)
}
Err(error) => {
if error.to_string().contains("failed to decrypt vault") {
term::warn_step(sp, "Broker unlock failed");
return Err(error);
}
term::warn_step(sp, &format!("Broker unlock failed: {error}"));
term::info("Run `ward unlock` before running protected commands.");
None
}
}
};
let event = SetupEvent {
event_type: "setup.completed",
project: &project_config.project,
source: &options.source,
vault: &vault_path,
imported,
removed_plaintext,
locked_env,
committed_vault: commit_vault,
unlock_created: unlock_session.is_some(),
unlock_expires_at: unlock_session
.as_ref()
.map(|session| session.expires_at.to_rfc3339()),
};
audit_logs::append_event(LogKind::Sessions, event)?;
if let Some(ref passphrase) = setup_passphrase_final {
let sp = term::spinner("Creating recovery key");
match recovery::create_recovery_files_with_material(
&project_config.project,
passphrase,
passphrase,
recovery_plaintext.as_deref(),
) {
Ok(recovery_file) => {
term::done(
sp,
&format!("Recovery key created {}", term::short_path(&recovery_file)),
);
project_config.recovery_created = true;
let _ = config::write_project_config(&cwd, &project_config, true);
#[cfg(not(coverage))]
{
term::blank();
let export = options.yes
|| inquire::Confirm::new(
"Export a recovery backup now? (store it somewhere safe — USB, cloud backup)",
)
.with_default(true)
.prompt()
.unwrap_or(false);
if export {
let dest = dirs::desktop_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."));
match recovery::export_recovery_file(
&project_config.project,
passphrase,
&dest,
) {
Ok(out_path) => {
project_config.backup_exported = true;
let _ = config::write_project_config(&cwd, &project_config, true);
term::ok(&format!("Backup saved {}", term::short_path(&out_path)));
let _ = std::process::Command::new("open")
.arg("-R")
.arg(&out_path)
.spawn();
}
Err(e) => {
term::warn(&format!("Export failed — {e}"));
term::info("Run `ward recovery export` manually.");
}
}
} else {
term::info("Run `ward recovery export` when ready.");
}
}
}
Err(error) => {
term::warn_step(sp, &format!("Recovery key creation failed: {error}"));
term::info("Run `ward recovery create` manually.");
}
}
}
term::blank();
if unlock_session.is_none() {
term::next(&format!(
"ward unlock --ttl {} — restore broker session",
options.unlock_ttl
));
}
term::blank();
if options.keep_plaintext {
term::warn("plaintext env was kept (--keep-plaintext)");
}
if let Some(rc) = ensure_shell_integration() {
term::ok(&format!("Shell integration added to {}", rc.display()));
prompt_shell_reload(&rc);
}
Ok(())
}
fn init(project: Option<String>, force: bool, bare: bool) -> Result<()> {
let cwd = env::current_dir()?;
let source = cwd.join(".env");
let vault_path = cwd.join(config::DEFAULT_VAULT_FILE);
if !bare && (source.exists() || vault_path.exists()) {
return setup(SetupOptions {
yes: true,
project,
source: PathBuf::from(".env"),
vault: PathBuf::from(config::DEFAULT_VAULT_FILE),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
});
}
init_bare(project, force)
}
fn init_bare(project: Option<String>, force: bool) -> Result<()> {
let cwd = env::current_dir()?;
let config = config::ProjectConfig::default_for_dir(&cwd, project)?;
let config_path = config::write_project_config(&cwd, &config, force)?;
let env_example = config::ensure_env_example(&cwd)?;
let agent_instructions = config::ensure_agent_instructions(&cwd, &config.project)?;
println!("Created {}", config_path.display());
if let Some(path) = env_example {
println!("Created or updated {}", path.display());
}
if let Some(path) = agent_instructions {
println!("Created or updated {}", path.display());
}
if cwd.join(".env").exists() {
println!("Warning: plaintext .env exists. Run ward import .env, then remove .env.");
}
if let Some(rc) = ensure_shell_integration() {
println!();
println!("Shell integration added to {}.", rc.display());
prompt_shell_reload(&rc);
}
Ok(())
}
fn import(source: PathBuf, explicit_vault: Option<PathBuf>) -> Result<()> {
let cwd = env::current_dir()?;
let mut config =
config::read_project_config(&cwd).context("missing .ward.json; run ward init first")?;
if env_file::is_locked_env_file(&source)? {
anyhow::bail!(
"{} is already an Ward locked marker; use ward env unlock to restore plaintext before importing",
source.display()
);
}
let passphrase = vault::read_new_passphrase()?;
let vault_path = match explicit_vault {
Some(vault) => {
config.vault = vault.clone();
config::write_project_config(&cwd, &config, true)?;
if vault.is_absolute() {
vault
} else {
cwd.join(vault)
}
}
None => registry::resolve_project(Some(&config.project), &cwd)
.ok()
.map(|resolved| resolved.vault)
.filter(|path| path.exists())
.unwrap_or_else(|| {
config::resolve_vault_path_with_passphrase(&cwd, &config, &passphrase)
}),
};
let written = vault::import_env_file(&source, &vault_path, &passphrase)?;
vault::decrypt_vault_file(&written, &passphrase)?;
env_file::lock_env_file(&source, &written)?;
registry::update_project_vault(&config.project, cwd.clone(), written.clone())?;
let event = VaultImportEvent {
event_type: "vault.import",
project: &config.project,
source: &source,
vault: &written,
};
audit_logs::append_event(LogKind::Sessions, event)?;
println!("Created encrypted vault {}", written.display());
println!("Locked {}", source.display());
Ok(())
}
fn register(project: String, path: Option<PathBuf>, explicit_vault: Option<PathBuf>) -> Result<()> {
let cwd = env::current_dir()?;
let project_path = path.unwrap_or(cwd.clone());
let vault_path = match explicit_vault {
Some(vault) if vault.is_absolute() => vault,
Some(vault) => project_path.join(vault),
None => {
let project_config = config::read_project_config(&project_path)
.context("missing .ward.json; run ward init first")?;
config::resolve_vault_path(&project_path, &project_config)
}
};
let registered = registry::register_project(project.clone(), project_path, vault_path)?;
println!("Registered {project}");
println!("Path: {}", registered.path.display());
println!("Vault: {}", registered.vault.display());
Ok(())
}
fn use_project(project: &str) -> Result<()> {
registry::set_active_project(project)?;
println!("Active Ward project: {project}");
Ok(())
}
fn projects_command(command: ProjectsCommand) -> Result<()> {
match command {
ProjectsCommand::List => {
let registry = registry::list_projects()?;
for (name, project) in registry.projects {
let active = if registry.active_project.as_deref() == Some(name.as_str()) {
"*"
} else {
" "
};
println!(
"{active} {name} path={} vault={}",
project.path.display(),
project.vault.display()
);
}
}
ProjectsCommand::Show { project } => {
let cwd = env::current_dir()?;
let resolved = registry::resolve_project(project.as_deref(), &cwd)?;
println!("Project: {}", resolved.name);
println!("Path: {}", resolved.path.display());
println!("Vault: {}", resolved.vault.display());
}
ProjectsCommand::Register {
project,
path,
vault,
} => register(project, path, vault)?,
ProjectsCommand::Use { project } => use_project(&project)?,
ProjectsCommand::Remove { project } => {
if registry::remove_project(&project)? {
println!("Removed project {project}");
} else {
println!("Project not found: {project}");
}
}
}
Ok(())
}
fn broker_command(command: BrokerCommand) -> Result<()> {
match command {
BrokerCommand::Status => {
let status = broker::status()?;
println!("{}", serde_json::to_string_pretty(&status)?);
}
BrokerCommand::Stop => {
broker::stop()?;
println!("Ward broker stopped.");
}
BrokerCommand::SocketPath => println!("{}", broker::socket_path().display()),
}
Ok(())
}
fn dashboard_command(command: Option<DashboardCommand>) -> Result<()> {
match command.unwrap_or(DashboardCommand::Tui) {
DashboardCommand::Start {
port,
no_open,
foreground,
json,
} => crate::webui::start_dashboard(crate::webui::DashboardStartOptions {
port,
open_browser: !no_open,
foreground,
json,
}),
DashboardCommand::Stop {
all,
pid,
port,
json,
} => crate::webui::stop_dashboards(crate::webui::DashboardStopOptions {
all,
pid,
port,
json,
}),
DashboardCommand::Status { json } => crate::webui::print_dashboard_status(json),
DashboardCommand::Tui => crate::dashboard::run_dashboard(),
}
}
fn worktrees_command(command: WorktreesCommand) -> Result<()> {
match command {
WorktreesCommand::List { project } => {
let state = worktrees::list_project(&project)?;
println!("{}", serde_json::to_string_pretty(&state)?);
}
WorktreesCommand::AllowRoot { project, path } => {
let root = worktrees::allow_root(&project, &path)?;
println!("Allowed worktree root for {project}: {}", root.display());
}
WorktreesCommand::RemoveRoot { project, path } => {
if worktrees::remove_root(&project, &path)? {
println!("Removed worktree root for {project}: {}", path.display());
} else {
println!("Worktree root not found for {project}: {}", path.display());
}
}
WorktreesCommand::Approve { request_id, json } => {
if let Some(worktree) = worktrees::approve_pending(request_id)? {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "approved",
"requestId": request_id,
"worktree": worktree.path,
}))?
);
return Ok(());
}
println!("Approved worktree {}", worktree.path.display());
} else {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "not_found",
"requestId": request_id,
}))?
);
return Ok(());
}
println!("Worktree request not found: {request_id}");
}
}
WorktreesCommand::Deny { request_id, json } => {
if worktrees::deny_pending(request_id)? {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "denied",
"requestId": request_id,
}))?
);
return Ok(());
}
println!("Denied worktree request {request_id}");
} else {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "not_found",
"requestId": request_id,
}))?
);
return Ok(());
}
println!("Worktree request not found: {request_id}");
}
}
}
Ok(())
}
fn env_command(command: EnvCommand) -> Result<()> {
match command {
EnvCommand::List { project } => {
let passphrase = vault::read_existing_passphrase()?;
let resolved = resolve_env_project_with_passphrase(project.as_deref(), &passphrase)?;
let names = with_passphrase_vault_access(&resolved, &passphrase, || {
env_file::list_env_names(&resolved.vault, &passphrase)
})?;
for name in names {
println!("{name}");
}
}
EnvCommand::Set {
project,
assignment,
} => {
let passphrase = vault::read_existing_passphrase()?;
let resolved = resolve_env_project_with_passphrase(project.as_deref(), &passphrase)?;
let key = with_passphrase_vault_access(&resolved, &passphrase, || {
env_file::set_env_value(&resolved.vault, &passphrase, &assignment)
})?;
env_file::refresh_locked_env(&resolved.path, &resolved.vault)?;
log_env_file_event("env.set", &resolved, None, Some(&key))?;
println!("Set encrypted env {key}");
}
EnvCommand::Unset { project, key } => {
let passphrase = vault::read_existing_passphrase()?;
let resolved = resolve_env_project_with_passphrase(project.as_deref(), &passphrase)?;
let removed = with_passphrase_vault_access(&resolved, &passphrase, || {
env_file::unset_env_value(&resolved.vault, &passphrase, &key)
})?;
env_file::refresh_locked_env(&resolved.path, &resolved.vault)?;
log_env_file_event("env.unset", &resolved, None, Some(&key))?;
if removed {
println!("Removed encrypted env {key}");
} else {
println!("Encrypted env not found: {key}");
}
}
EnvCommand::Unlock {
project,
output,
force,
} => {
let passphrase = vault::read_existing_passphrase()?;
let resolved = resolve_env_project_with_passphrase(project.as_deref(), &passphrase)?;
let output = project_relative_path(&resolved.path, output);
with_passphrase_vault_access(&resolved, &passphrase, || {
env_file::unlock_env_file(&output, &resolved.vault, &passphrase, force)
})?;
log_env_file_event("env.unlock", &resolved, Some(&output), None)?;
println!("Wrote plaintext env {}", output.display());
println!("Run ward env lock when you are done.");
}
EnvCommand::Lock { project, source } => {
let passphrase = vault::read_existing_passphrase()?;
let resolved = resolve_env_project_with_passphrase(project.as_deref(), &passphrase)?;
let source = project_relative_path(&resolved.path, source);
with_passphrase_vault_access(&resolved, &passphrase, || {
env_file::lock_plaintext_source(&source, &resolved.vault, &passphrase)
})?;
log_env_file_event("env.lock", &resolved, Some(&source), None)?;
println!("Re-encrypted vault and locked {}", source.display());
}
EnvCommand::Export {
project,
output,
force,
unsafe_stdout,
} => {
let passphrase = vault::read_existing_passphrase()?;
let resolved = resolve_env_project_with_passphrase(project.as_deref(), &passphrase)?;
if unsafe_stdout {
let plaintext = with_passphrase_vault_access(&resolved, &passphrase, || {
let plaintext = vault::decrypt_vault_file(&resolved.vault, &passphrase)?;
vault::validate_dotenv(&plaintext)?;
Ok(plaintext)
})?;
print!("{plaintext}");
log_env_file_event("env.export.stdout", &resolved, None, None)?;
} else {
let output_path = match output {
Some(path) => path,
None => ".env.export".into(),
};
let output = project_relative_path(&resolved.path, output_path);
with_passphrase_vault_access(&resolved, &passphrase, || {
env_file::export_env_file(&output, &resolved.vault, &passphrase, force)
})?;
log_env_file_event("env.export", &resolved, Some(&output), None)?;
println!("Exported plaintext env {}", output.display());
}
}
}
Ok(())
}
fn resolve_env_project_with_passphrase(
project: Option<&str>,
passphrase: &str,
) -> Result<registry::ResolvedProject> {
let cwd = env::current_dir()?;
let resolved = registry::resolve_project_with_passphrase(project, &cwd, passphrase)?;
if resolved.vault.exists() {
registry::update_project_vault(
&resolved.name,
resolved.path.clone(),
resolved.vault.clone(),
)?;
}
Ok(resolved)
}
fn project_relative_path(project_path: &Path, path: PathBuf) -> PathBuf {
if path.is_absolute() {
path
} else {
project_path.join(path)
}
}
fn log_env_file_event(
event_type: &'static str,
resolved: ®istry::ResolvedProject,
env_file: Option<&Path>,
key: Option<&str>,
) -> Result<()> {
let event = EnvFileEvent {
event_type,
project: &resolved.name,
vault: &resolved.vault,
env_file,
key,
};
audit_logs::append_event(LogKind::Sessions, event)
}
fn with_passphrase_vault_access<T>(
resolved: ®istry::ResolvedProject,
passphrase: &str,
operation: impl FnOnce() -> Result<T>,
) -> Result<T> {
let active_expires_at = broker::active_session_expiry(&resolved.name, &resolved.vault)?;
let active_ttl = active_expires_at
.and_then(|expires_at| remaining_session_ttl(expires_at, chrono::Utc::now()));
if active_ttl.is_some() {
broker::stop().context("failed to restore active broker session before vault access")?;
}
let result = operation();
if let Some(ttl) = active_ttl {
let refresh_result =
broker::unlock_project(&resolved.name, &resolved.vault, passphrase, ttl);
match (&result, refresh_result) {
(Ok(_), Err(error)) => {
return Err(error).context(
"vault operation succeeded, but Ward could not refresh the active broker session",
);
}
(Err(_), Err(error)) => {
eprintln!("Warning: Ward could not refresh the active broker session: {error}");
}
_ => {}
}
}
result
}
fn remaining_session_ttl(
expires_at: chrono::DateTime<chrono::Utc>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<chrono::Duration> {
let ttl = expires_at.signed_duration_since(now);
(ttl.num_seconds() > 0).then_some(ttl)
}
fn verified_no_prompt_context(
cwd: &Path,
resolved: ®istry::ResolvedProject,
context_options: &AgentContextOptions,
) -> Result<Option<context::VerifiedContext>> {
let Some(agent_name) = context_options.agent.as_deref() else {
let problem = context::ContextProblem::ContextRequired {
missing: vec!["agent"],
};
println!("{}", context::context_problem_json(&problem)?);
return Ok(None);
};
let agent = agents::ensure_agent(&resolved.name, agent_name)?;
if let Some(claimed_key) = context_options.agent_key_id.as_deref() {
if claimed_key != agent.agent_key_id {
let problem = context::ContextProblem::ContextMismatch {
field: "agentKeyId",
claimed: claimed_key.to_string(),
actual: agent.agent_key_id,
};
println!("{}", context::context_problem_json(&problem)?);
return Ok(None);
}
}
let claimed = context::ClaimedContext {
agent: context_options.agent.clone(),
agent_key_id: Some(agent.agent_key_id.clone()),
worktree: context_options.worktree.clone(),
branch: context_options.branch.clone(),
git_remote: context_options.git_remote.clone(),
commit: context_options.commit.clone(),
};
match context::verify_no_prompt_context(&claimed, cwd, resolved, agent.agent_key_id) {
Ok(verified) => Ok(Some(verified)),
Err(problem) => {
println!("{}", context::context_problem_json(&problem)?);
Ok(None)
}
}
}
fn enforce_worktree_for_no_prompt(
resolved: ®istry::ResolvedProject,
verified: &context::VerifiedContext,
) -> Result<bool> {
let registry = registry::load_registry()?;
let Some(registered) = registry.projects.get(&resolved.name) else {
return Ok(true);
};
match worktrees::evaluate_worktree(registered, &resolved.name, verified)? {
worktrees::WorktreeDecision::Trusted { .. } => Ok(true),
worktrees::WorktreeDecision::AutoBound { match_kind } => {
let response = WorktreeBoundResponse {
status: "worktree_bound",
project: &resolved.name,
worktree: &verified.worktree,
match_kind: &match_kind,
continued: true,
};
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(true)
}
worktrees::WorktreeDecision::ApprovalRequired { request } => {
let response = WorktreeRequiredResponse {
status: "worktree_approval_required",
project: &resolved.name,
worktree: &request.path,
git_remote: &request.git_remote,
branch: &request.branch,
commit: &request.commit,
reason: &request.reason,
approve_command: format!("ward worktrees approve {}", request.id),
deny_command: format!("ward worktrees deny {}", request.id),
};
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(false)
}
worktrees::WorktreeDecision::Denied { reason } => {
let response = serde_json::json!({
"status": "worktree_denied",
"project": resolved.name,
"worktree": verified.worktree,
"reason": reason,
});
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(false)
}
}
}
fn request(
profile: Option<String>,
context_options: AgentContextOptions,
action: Option<String>,
command: Option<String>,
env_names: Vec<String>,
json: bool,
no_prompt: bool,
) -> Result<()> {
let cwd = env::current_dir()?;
let resolved = registry::resolve_project(None, &cwd)?;
let config = config::read_project_config(&resolved.path)?;
let git = git_context::collect_git_context(&cwd);
let branch = context_options.branch.clone().or(git.branch.clone());
let resolved_profile =
resolve_profile(&config, profile.as_deref(), action, command, env_names)?;
let access = AccessRequest {
project: resolved.name.clone(),
agent: context_options.agent.clone(),
branch,
action: resolved_profile.action,
command: resolved_profile.command,
env: resolved_profile.env_names,
};
let evaluation = evaluate_access(&config, &access);
if no_prompt {
if !json {
anyhow::bail!("--no-prompt requires --json");
}
let Some(verified_context) = verified_no_prompt_context(&cwd, &resolved, &context_options)?
else {
return Ok(());
};
if !enforce_worktree_for_no_prompt(&resolved, &verified_context)? {
return Ok(());
}
let pending =
create_run_pending_request(&access, &evaluation, &git, Some(verified_context))?;
let request_event = RequestEvent {
access: &pending.access,
policy: &pending.policy,
git: &pending.git,
verified_context: pending.verified_context.as_ref(),
};
audit_logs::append_event(LogKind::Requests, request_event)?;
let response = serde_json::to_string_pretty(&pending_requests::response_for(&pending))?;
println!("{response}");
return Ok(());
}
let decision = decide_access(&access, &evaluation, true)?;
let critical_confirmation = critical_confirmation_for_decision(&decision, &evaluation);
let receipt_context = Some(grants::GrantReceiptContext::synthetic(
critical_confirmation,
));
let persisted_grant =
grants::persist_grant(&access, &decision, &resolved.vault, receipt_context)?;
let receipt = persisted_grant
.as_ref()
.and_then(|grant| grant.receipt.as_ref());
let request_event = RequestEvent {
access: &access,
policy: &evaluation,
git: &git,
verified_context: None,
};
let approval_event = ApprovalEvent {
decision: &decision,
persisted_grant: persisted_grant.as_ref().map(|grant| grant.id),
approval_receipt_hash: receipt.map(|receipt| receipt.payload_hash.as_str()),
signer_key_id: receipt.map(|receipt| receipt.signer_key_id.as_str()),
signature_algorithm: receipt.map(|receipt| receipt.signature_algorithm.as_str()),
critical_confirmation,
human_proof: approval_human_proof(decision.source),
};
audit_logs::append_event(LogKind::Requests, request_event)?;
audit_logs::append_event(LogKind::Approvals, &approval_event)?;
if json {
println!("{}", serde_json::to_string_pretty(&approval_event)?);
} else if decision.approved {
println!("Approved: {}", decision.approved_env.join(", "));
} else {
println!("Denied");
}
Ok(())
}
fn allow(
profile: Option<String>,
scope: Option<ApprovalScope>,
agent: Option<String>,
branch: Option<String>,
command: Option<String>,
env_names: Vec<String>,
) -> Result<()> {
let cwd = env::current_dir()?;
let resolved = registry::resolve_project(None, &cwd)?;
let config = config::read_project_config(&resolved.path)?;
let resolved_profile = resolve_profile(
&config,
profile.as_deref(),
Some("Manual allow grant".to_string()),
command,
env_names,
)?;
let scope = match scope {
Some(scope) => scope,
None if profile.is_some() => resolved_profile.default_scope,
None => anyhow::bail!("--scope is required unless --profile is used"),
};
if matches!(scope, ApprovalScope::Once | ApprovalScope::Deny) {
anyhow::bail!("ward allow supports session, branch, and always scopes");
}
let git = git_context::collect_git_context(&cwd);
let branch = branch.or(git.branch.clone());
let access = AccessRequest {
project: resolved.name,
agent,
branch,
action: resolved_profile.action,
command: resolved_profile.command,
env: resolved_profile.env_names,
};
let evaluation = evaluate_access(&config, &access);
if detection::has_critical_findings(&evaluation.findings) {
anyhow::bail!(
"critical exploit findings cannot be stored as durable allow grants; use ward request and approve once with --confirm-critical"
);
}
approvals::validate_scope_for_findings(scope, &evaluation.findings)?;
let receipt_context = Some(grants::GrantReceiptContext::synthetic(false));
let source = approvals::ApprovalSource::ManualAllow;
let grant =
grants::persist_manual_grant(&access, scope, source, &resolved.vault, receipt_context)?;
let receipt = grant.receipt.as_ref();
let mut decision = grants::approval_from_grant(&access, &grant);
decision.source = approvals::ApprovalSource::ManualAllow;
let approval_event = ApprovalEvent {
decision: &decision,
persisted_grant: Some(grant.id),
approval_receipt_hash: receipt.map(|receipt| receipt.payload_hash.as_str()),
signer_key_id: receipt.map(|receipt| receipt.signer_key_id.as_str()),
signature_algorithm: receipt.map(|receipt| receipt.signature_algorithm.as_str()),
critical_confirmation: false,
human_proof: approval_human_proof(decision.source),
};
audit_logs::append_event(LogKind::Approvals, approval_event)?;
println!("Created {} grant {}", scope, grant.id);
Ok(())
}
fn grants_command(command: GrantsCommand) -> Result<()> {
match command {
GrantsCommand::List => {
for grant in grants::load_grants()? {
let expires = match grant.expires_at {
Some(value) => value.to_rfc3339(),
None => "-".to_string(),
};
let status =
grant_status_label(grants::grant_integrity_status(&grant, chrono::Utc::now()));
let receipt_hash = grant
.receipt
.as_ref()
.map(|receipt| receipt.payload_hash.as_str())
.unwrap_or("-");
println!(
"{} scope={:?} status={} project={} command=\"{}\" env={} agent={} branch={} expires={} receipt={}",
grant.id,
grant.scope,
status,
grant.project,
grant.command,
grant.approved_env.join(","),
grant.agent.as_deref().unwrap_or("-"),
grant.branch.as_deref().unwrap_or("-"),
expires,
receipt_hash,
);
}
}
GrantsCommand::Revoke { grant_id } => {
if grants::revoke_grant(grant_id)? {
println!("Revoked grant {grant_id}");
} else {
println!("Grant not found: {grant_id}");
}
}
GrantsCommand::Prune => {
let pruned = grants::prune_expired_grants()?;
println!("Pruned {pruned} expired grant(s).");
}
}
Ok(())
}
fn approve(
request_id: uuid::Uuid,
scope: ApprovalScope,
confirm_critical: bool,
agent_mediated: bool,
json: bool,
) -> Result<()> {
match approve_inner(request_id, scope, confirm_critical, agent_mediated) {
Ok(response) => {
if json {
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
println!("Approved request {request_id}: grant {}", response.grant_id);
}
Ok(())
}
Err(error) if json && is_unlock_or_signing_error(&error) => {
print_unlock_required_json(error.to_string())?;
Ok(())
}
Err(error) if json => {
if print_pending_request_error_json(request_id, &error)? {
Ok(())
} else {
Err(error)
}
}
Err(error) => Err(error),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ApproveJsonResponse {
status: &'static str,
request_id: uuid::Uuid,
project: String,
grant_id: uuid::Uuid,
approval_receipt_hash: Option<String>,
signer_key_id: Option<String>,
signature_algorithm: Option<String>,
approval_source: approvals::ApprovalSource,
}
fn approve_inner(
request_id: uuid::Uuid,
scope: ApprovalScope,
confirm_critical: bool,
agent_mediated: bool,
) -> Result<ApproveJsonResponse> {
if scope == ApprovalScope::Deny {
anyhow::bail!("use ward deny for denied requests");
}
let pending = pending_requests::load_pending_request(request_id)?;
let cwd = env::current_dir()?;
let resolved = registry::resolve_project(Some(&pending.access.project), &cwd)?;
let critical = detection::has_critical_findings(&pending.policy.findings);
validate_pending_approval(&pending, scope, confirm_critical)?;
let source = if agent_mediated {
approvals::ApprovalSource::AgentMediated
} else {
approvals::ApprovalSource::LocalTty
};
let receipt_context = Some(grants::GrantReceiptContext {
request_id,
critical_confirmation: critical && confirm_critical,
verified_context: pending.verified_context.clone(),
});
let access = &pending.access;
let vault = &resolved.vault;
let grant = grants::persist_manual_grant(access, scope, source, vault, receipt_context)?;
pending_requests::consume_pending_request(request_id)?;
let receipt = grant.receipt.as_ref();
let mut decision = grants::approval_from_grant(&pending.access, &grant);
decision.source = source;
let approval_event = ApprovalEvent {
decision: &decision,
persisted_grant: Some(grant.id),
approval_receipt_hash: receipt.map(|receipt| receipt.payload_hash.as_str()),
signer_key_id: receipt.map(|receipt| receipt.signer_key_id.as_str()),
signature_algorithm: receipt.map(|receipt| receipt.signature_algorithm.as_str()),
critical_confirmation: critical && confirm_critical,
human_proof: approval_human_proof(source),
};
audit_logs::append_event(LogKind::Approvals, approval_event)?;
Ok(ApproveJsonResponse {
status: "approved",
request_id,
project: pending.access.project,
grant_id: grant.id,
approval_receipt_hash: receipt.map(|receipt| receipt.payload_hash.clone()),
signer_key_id: receipt.map(|receipt| receipt.signer_key_id.clone()),
signature_algorithm: receipt.map(|receipt| receipt.signature_algorithm.clone()),
approval_source: source,
})
}
fn validate_pending_approval(
pending: &pending_requests::PendingRequest,
scope: ApprovalScope,
confirm_critical: bool,
) -> Result<()> {
let critical = detection::has_critical_findings(&pending.policy.findings);
if critical && !confirm_critical {
anyhow::bail!("critical request requires --confirm-critical");
}
approvals::validate_scope_for_findings(scope, &pending.policy.findings)
}
fn deny(request_id: uuid::Uuid, agent_mediated: bool, json: bool) -> Result<()> {
let pending = match pending_requests::consume_pending_request(request_id) {
Ok(pending) => pending,
Err(error) if json => {
if print_pending_request_error_json(request_id, &error)? {
return Ok(());
}
return Err(error);
}
Err(error) => return Err(error),
};
let source = if agent_mediated {
approvals::ApprovalSource::AgentMediated
} else {
approvals::ApprovalSource::LocalTty
};
let decision = ApprovalDecision {
approved: false,
scope: ApprovalScope::Deny,
approved_env: Vec::new(),
denied_env: pending.access.env.clone(),
source,
grant_id: None,
};
let approval_event = ApprovalEvent {
decision: &decision,
persisted_grant: None,
approval_receipt_hash: None,
signer_key_id: None,
signature_algorithm: None,
critical_confirmation: false,
human_proof: approval_human_proof(source),
};
audit_logs::append_event(LogKind::Approvals, approval_event)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "denied",
"requestId": request_id,
"project": pending.access.project,
"approvalSource": source,
}))?
);
} else {
println!("Denied request {request_id}");
}
Ok(())
}
fn is_unlock_or_signing_error(error: &anyhow::Error) -> bool {
let message = error.to_string();
message.contains("signing_key_unavailable")
|| message.contains("unlock_required")
|| message.contains("missing broker unlock session")
|| message.contains("expired broker unlock session")
|| message.contains("Ward broker is unavailable")
}
fn print_unlock_required_json(reason: String) -> Result<()> {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "unlock_required",
"reason": reason,
"unlockCommand": "ward unlock --ttl 8h",
}))?
);
Ok(())
}
fn print_pending_request_error_json(request_id: uuid::Uuid, error: &anyhow::Error) -> Result<bool> {
let path = pending_requests::pending_request_path(request_id);
let (status, reason) = if !path.exists() {
("not_found", "pending_request_not_found")
} else {
let message = error.to_string();
if message.contains("failed to parse") {
("invalid_request", "pending_request_malformed")
} else if message.contains("pending request") && message.contains("expired") {
("invalid_request", "pending_request_expired")
} else if message.contains("failed to read") {
("invalid_request", "pending_request_unreadable")
} else {
return Ok(false);
}
};
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": status,
"requestId": request_id,
"reason": reason,
}))?
);
Ok(true)
}
#[cfg(any(test, coverage))]
fn run(options: RunOptions) -> Result<()> {
run_with_context(options, AgentContextOptions::default())
}
fn run_with_context(mut options: RunOptions, context_options: AgentContextOptions) -> Result<()> {
if reject_misplaced_run_flags(&options.command)? {
return Ok(());
}
let cwd = env::current_dir()?;
let resolved = registry::resolve_project(options.project.as_deref(), &cwd)?;
let config = config::read_project_config(&resolved.path)?;
let git = git_context::collect_git_context(&cwd);
let branch = options.branch.or(git.branch.clone());
let mut context_options = context_options;
let human_terminal = crate::human::is_human_terminal();
if context_options.agent.is_none() {
context_options.agent = options.agent.clone();
}
if context_options.branch.is_none() {
context_options.branch = branch.clone();
}
if human_terminal {
if context_options.agent.is_none() {
context_options.agent = Some("human".to_string());
}
if options.agent.is_none() {
options.agent = Some("human".to_string());
}
if context_options.worktree.is_none() {
context_options.worktree = git.worktree_path.as_deref().map(PathBuf::from);
}
if context_options.commit.is_none() {
context_options.commit = git.commit.clone();
}
if context_options.git_remote.is_none() {
context_options.git_remote = git.remote.clone();
}
if options.env_names.is_empty() && options.profile.is_none() {
options.env_names = broker::list_vault_keys(&resolved.name, &resolved.vault).context(
"human mode requires an active broker session; run `ward human` or `ward unlock --ttl 8h`",
)?;
}
}
let resolved_profile = resolve_run_profile(
&config,
options.profile.as_deref(),
options.action,
options.env_names,
options.command,
human_terminal,
)?;
let command_text = resolved_profile.command.clone();
let access = AccessRequest {
project: resolved.name.clone(),
agent: options.agent,
branch,
action: resolved_profile.action,
command: command_text.clone(),
env: resolved_profile.env_names.clone(),
};
let evaluation = evaluate_access(&config, &access);
let mut verified_context = None;
let decision = if human_terminal && !options.no_prompt {
ApprovalDecision {
approved: true,
scope: ApprovalScope::Once,
approved_env: resolved_profile.env_names.clone(),
denied_env: Vec::new(),
source: approvals::ApprovalSource::LocalTty,
grant_id: None,
}
} else if options.no_prompt {
if !options.json {
anyhow::bail!("--no-prompt requires --json");
}
let Some(context) = verified_no_prompt_context(&cwd, &resolved, &context_options)? else {
return Ok(());
};
if !enforce_worktree_for_no_prompt(&resolved, &context)? {
return Ok(());
}
verified_context = Some(context);
let Some(decision) =
non_interactive_decision_with_context(&access, &evaluation, verified_context.as_ref())?
else {
let pending =
create_run_pending_request(&access, &evaluation, &git, verified_context.clone())?;
let request_event = RequestEvent {
access: &pending.access,
policy: &pending.policy,
git: &pending.git,
verified_context: pending.verified_context.as_ref(),
};
audit_logs::append_event(LogKind::Requests, request_event)?;
print_run_approval_required(&pending)?;
return Ok(());
};
if !decision.approved {
print_run_denied(&access, &evaluation)?;
return Ok(());
}
decision
} else {
decide_access(&access, &evaluation, true)?
};
let critical_confirmation = critical_confirmation_for_decision(&decision, &evaluation);
let receipt_context = Some(grants::GrantReceiptContext {
request_id: uuid::Uuid::new_v4(),
critical_confirmation,
verified_context: verified_context.clone(),
});
let persisted_grant =
grants::persist_grant(&access, &decision, &resolved.vault, receipt_context)?;
let receipt = persisted_grant
.as_ref()
.and_then(|grant| grant.receipt.as_ref());
let request_event = RequestEvent {
access: &access,
policy: &evaluation,
git: &git,
verified_context: verified_context.as_ref(),
};
let approval_event = ApprovalEvent {
decision: &decision,
persisted_grant: persisted_grant.as_ref().map(|grant| grant.id),
approval_receipt_hash: receipt.map(|receipt| receipt.payload_hash.as_str()),
signer_key_id: receipt.map(|receipt| receipt.signer_key_id.as_str()),
signature_algorithm: receipt.map(|receipt| receipt.signature_algorithm.as_str()),
critical_confirmation,
human_proof: approval_human_proof(decision.source),
};
audit_logs::append_event(LogKind::Requests, request_event)?;
audit_logs::append_event(LogKind::Approvals, approval_event)?;
if !decision.approved {
anyhow::bail!("Ward access denied");
}
let grant_id = effective_grant_id(&decision, persisted_grant.as_ref());
let approval_receipt_hash = grant_receipt_hash(&decision, persisted_grant.as_ref())?;
let started_event = ExecutionStartedEvent {
event_type: "execution.started",
project: &resolved.name,
agent: &access.agent,
branch: &access.branch,
declared_action: &access.action,
requested_command: &command_text,
cwd: &cwd,
git: &git,
requested_env: &evaluation.requested_env,
injected_env: &decision.approved_env,
policy_findings: &evaluation.findings,
approval_scope: decision.scope,
approval_source: decision.source,
grant_id,
approval_receipt_hash: approval_receipt_hash.as_deref(),
agent_key_id: verified_agent_key_id(verified_context.as_ref()),
verified_context: verified_context.as_ref(),
};
audit_logs::append_event(LogKind::Executions, started_event)?;
consume_once_grant_if_reused(&decision)?;
let outcome = if options.no_prompt {
let context = verified_context
.as_ref()
.expect("verified in no-prompt mode");
let proof_payload =
serde_json::to_string(context).expect("verified context should serialize");
let proof = agents::sign_payload(&resolved.name, &context.agent, &proof_payload)?;
match broker::execute(
&resolved.name,
&resolved.vault,
&cwd,
decision.approved_env.clone(),
resolved_profile.command_args,
None,
Some(proof),
) {
Ok(outcome) => outcome,
Err(error) => {
if let Some(missing_env) = broker_vault_key_missing_envs(&error) {
print_run_vault_key_missing(&access, &evaluation, missing_env)?;
return Ok(());
}
let reason = error.to_string();
print_run_unlock_required(&access, &evaluation, Some(&reason))?;
return Ok(());
}
}
} else {
match broker::execute(
&resolved.name,
&resolved.vault,
&cwd,
decision.approved_env.clone(),
resolved_profile.command_args.clone(),
if human_terminal {
Some(crate::human::current_shell_pid())
} else {
None
},
None,
) {
Ok(outcome) => outcome,
Err(broker_err) => {
let has_session = unlock::active_run_lookup(&resolved.name, &resolved.vault)
.map(|r| !matches!(r, unlock::RunUnlockLookup::Missing))
.unwrap_or(false);
if has_session {
anyhow::bail!(
"broker session exists but broker is not running ({})\nRun `ward unlock` to restore the session.",
broker_err
);
}
let passphrase = vault::read_existing_passphrase()?;
runner::run_command(RunCommandRequest {
cwd: cwd.clone(),
vault: resolved.vault.clone(),
env_names: decision.approved_env.clone(),
command: resolved_profile.command_args,
passphrase,
inherited_env: std::env::vars().collect(),
cancellation: None,
human_shell_pid: if human_terminal {
Some(crate::human::current_shell_pid())
} else {
None
},
child_pid: None,
})?
}
}
};
let execution_event = ExecutionEvent {
event_type: "execution.finished",
project: &resolved.name,
agent: &access.agent,
branch: &access.branch,
declared_action: &access.action,
requested_command: &command_text,
cwd: &cwd,
git: &git,
requested_env: &evaluation.requested_env,
injected_env: &decision.approved_env,
policy_findings: &evaluation.findings,
approval_scope: decision.scope,
approval_source: decision.source,
grant_id,
approval_receipt_hash: approval_receipt_hash.as_deref(),
agent_key_id: verified_agent_key_id(verified_context.as_ref()),
verified_context: verified_context.as_ref(),
outcome: &outcome,
};
let finish_result = audit_logs::append_event(LogKind::Executions, execution_event);
let anomaly_result = log_anomaly_alerts(&config, grant_id);
let alert_result = if outcome.redaction_alerts > 0 {
let output_redaction_event = OutputRedactionEvent {
event_type: "output.redaction",
command: &command_text,
count: outcome.redaction_alerts,
alerts: &outcome.output_alerts,
};
audit_logs::append_event(LogKind::Alerts, output_redaction_event)
} else {
Ok(())
};
handle_post_run_logging_result(outcome.exit_code, finish_result.and(alert_result))?;
warn_anomaly_failure(anomaly_result);
if outcome.exit_code != 0 {
return Err(ChildExit::new(outcome.exit_code).into());
}
Ok(())
}
fn reject_misplaced_run_flags(command: &[String]) -> Result<bool> {
if !command.iter().any(|arg| arg == "--no-prompt") {
return Ok(false);
}
let response = InvalidInvocationResponse {
status: "invalid_invocation",
reason: "ward_flags_after_separator",
message: "Move Ward flags before --.",
correct_example: "ward run --json --no-prompt --env DATABASE_URI -- <command>",
};
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(true)
}
fn doctor() -> Result<()> {
let cwd = env::current_dir()?;
let config_path = config::config_path(&cwd);
let plaintext_env = cwd.join(".env");
let project_name = cwd
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
term::header_cmd("doctor", &project_name);
term::section("config");
if !config_path.exists() {
term::fail(".ward.json missing — run ward setup");
term::blank();
return Ok(());
}
term::ok(&format!(".ward.json {}", term::short_path(&config_path)));
let project_config = config::read_project_config(&cwd);
match &project_config {
Ok(cfg) => {
term::ok(&format!("project {}", cfg.project));
let vault_path = doctor_vault_path(&cwd, cfg);
if vault_path.exists() {
term::ok(&format!("vault {}", term::short_path(&vault_path)));
} else {
term::fail(&format!(
"vault not found {}",
term::short_path(&vault_path)
));
}
}
Err(e) => {
term::fail(&format!("config parse error — {e}"));
}
}
term::section("secrets");
match &project_config {
Ok(cfg) => {
let vault_path = doctor_vault_path(&cwd, cfg);
match env_file::inspect_env_file(&plaintext_env, &vault_path) {
Ok(env_file::EnvFileState::Locked) => term::ok(".env locked"),
Ok(env_file::EnvFileState::StaleLocked) => {
term::warn(".env locked but stale — run ward env lock")
}
Ok(env_file::EnvFileState::Plaintext) => {
term::warn(".env is plaintext — run ward env lock")
}
Ok(env_file::EnvFileState::Missing) => term::warn(".env missing"),
Err(e) => term::fail(&format!(".env check failed — {e}")),
}
}
Err(_) if plaintext_env.exists() => {
term::warn(".env is plaintext — run ward setup or ward import .env");
}
Err(_) => term::warn(".env missing"),
}
let secret_files = likely_secret_env_files(&cwd)?;
if secret_files.is_empty() {
term::ok("no .env.* secret variants found");
} else {
for path in &secret_files {
term::warn(&format!(
"plaintext env variant {}",
term::short_path(path)
));
}
}
term::section("gitignore");
check_gitignore(&cwd)?;
let gitignore_path = cwd.join(".gitignore");
if gitignore_path.exists() {
match fs::read_to_string(&gitignore_path) {
Ok(contents) if contents.contains(config::WARD_JSON_GITIGNORE_ENTRY) => {
term::ok(".ward.json excluded");
}
Ok(_) => {
term::warn(".ward.json not in .gitignore — vault nonce may leak into git history");
}
Err(_) => {}
}
}
term::section("broker");
match registry::resolve_project(None, &cwd) {
Ok(project) => {
term::ok(&format!("project {}", project.name));
if !project.vault.exists() {
term::warn(&format!(
"vault not found {}",
term::short_path(&project.vault)
));
}
match broker::status() {
Ok(status) if status.running => {
let active = status
.sessions
.iter()
.any(|s| s.project == project.name && s.vault == project.vault);
if active {
term::ok("session active — secrets unlocked");
} else {
term::warn("no active session — run ward unlock --ttl 8h");
}
}
Ok(_) => term::warn("broker not running — run ward unlock --ttl 8h"),
Err(e) => term::fail(&format!("broker status failed — {e}")),
}
}
Err(e) => term::fail(&format!("registry resolve failed — {e}")),
}
term::section("human mode");
let human = crate::human::runtime_diagnostics();
if human.shell_hooks_loaded {
term::ok("shell hooks loaded");
} else {
term::warn("shell hooks not loaded — reload your shell, then run ward human");
}
if human.guardian_socket_exists {
term::ok(&format!("guardian active for shell {}", human.shell_pid));
} else {
term::warn(&format!(
"guardian missing for shell {} — run ward human",
human.shell_pid
));
term::info(&format!(
"expected socket {}",
term::short_path(&human.socket_path)
));
}
if human.stale_guardian_pids.is_empty() && human.stale_run_dirs.is_empty() {
term::ok("no stale human runtime files");
} else {
if !human.stale_guardian_pids.is_empty() {
term::warn(&format!(
"stale guardian process(es): {}",
human
.stale_guardian_pids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}
for dir in &human.stale_run_dirs {
term::warn(&format!("stale runtime dir {}", term::short_path(dir)));
}
term::info("run ward human to clean stale human runtime state");
}
term::section("dashboard");
match crate::webui::dashboard_diagnostics() {
Ok(instances) if instances.is_empty() => {
term::ok("no standalone browser dashboards running");
term::info("run ward dashboard start to open the browser dashboard");
}
Ok(instances) => {
term::ok(&format!(
"{} standalone browser dashboard(s) running",
instances.len()
));
for instance in instances {
term::info(&format!(
"pid={} port={} project={}",
instance.pid,
instance.port,
instance.started_project.as_deref().unwrap_or("-")
));
}
}
Err(e) => term::fail(&format!("dashboard status failed — {e}")),
}
term::section("grants");
match grants::load_grants() {
Ok(loaded) => {
let now = chrono::Utc::now();
let unsigned = loaded
.iter()
.filter(|g| {
grants::grant_integrity_status(g, now)
== grants::GrantIntegrityStatus::LegacyUnsigned
})
.count();
let invalid = loaded
.iter()
.filter(|g| {
grants::grant_integrity_status(g, now) == grants::GrantIntegrityStatus::Invalid
})
.count();
if unsigned == 0 && invalid == 0 {
term::ok("all approval grants signed and valid");
}
if unsigned > 0 {
term::warn(&format!(
"{unsigned} legacy unsigned grant(s) — re-approve them"
));
}
if invalid > 0 {
term::warn(&format!(
"{invalid} invalid grant signature(s) — revoke and re-approve"
));
}
}
Err(e) => term::fail(&format!("grant check failed — {e}")),
}
term::section("logs");
match audit_logs::entry_count(LogKind::Alerts) {
Ok(0) => term::ok("no alerts"),
Ok(n) => term::warn(&format!("{n} alert(s) — run ward logs view alerts")),
Err(e) => term::fail(&format!("alert log check failed — {e}")),
}
term::section("recovery");
if let Ok(cfg) = &project_config {
let recovery_dir = logs::recovery_dir();
let key_files_exist = recovery_dir.exists()
&& fs::read_dir(&recovery_dir)
.map(|mut d| {
d.any(|e| {
e.map(|e| e.path().extension().and_then(|x| x.to_str()) == Some("key"))
.unwrap_or(false)
})
})
.unwrap_or(false);
if cfg.recovery_created && !key_files_exist {
term::fail("recovery key missing — run: ward recovery create");
term::info(&format!("expected in {}", term::short_path(&recovery_dir)));
} else if cfg.recovery_created {
term::ok("recovery key present");
if !cfg.backup_exported {
term::warn("no backup exported — run: ward recovery export");
} else {
term::ok("recovery backup exported");
}
} else {
term::warn("recovery key not created — run: ward recovery create");
}
if vault::test_passphrase().is_some() {
let passphrase = vault::test_passphrase().unwrap();
if !recovery::recovery_file_exists(&cfg.project, &passphrase) {
term::fail("recovery file not found at derived path — run: ward recovery create");
}
}
} else {
term::warn("unable to check recovery — config not readable");
}
term::blank();
Ok(())
}
fn doctor_vault_path(cwd: &Path, cfg: &config::ProjectConfig) -> PathBuf {
registry::resolve_project(Some(&cfg.project), cwd)
.map(|resolved| resolved.vault)
.unwrap_or_else(|_| config::resolve_vault_path(cwd, cfg))
}
#[cfg(any(test, coverage))]
fn signing_lookup_message(result: Result<unlock::RunSigningLookup>) -> String {
match result {
Ok(unlock::RunSigningLookup::Available(_)) => {
"[ok] Active signing key session is readable.".to_string()
}
Ok(unlock::RunSigningLookup::Missing) => {
"! No active signing key session. Run ward unlock --ttl 8h.".to_string()
}
Ok(unlock::RunSigningLookup::MaterialUnavailable { reason }) => {
format!(
"! Active signing key session is unavailable ({reason}). Run ward unlock --ttl 8h."
)
}
Err(error) => format!("! Signing key session check failed: {error}"),
}
}
fn logs(command: Option<LogsCommand>, kind: Option<LogKind>) -> Result<()> {
match command {
Some(LogsCommand::View { kind }) => {
ensure_logs_passphrase()?;
warn_log_view_access();
let output = render_log_events(&audit_logs::decrypt_events(kind)?)?;
if !output.is_empty() {
println!("{output}");
}
}
Some(LogsCommand::Verify { kind, full }) => {
let reports = if full {
ensure_logs_passphrase()?;
audit_logs::verify_logs_full(kind)?
} else {
audit_logs::verify_logs(kind)?
};
for report in reports {
println!(
"[ok] {} entries={} path={}",
report.kind.as_str(),
report.entries,
report.path.display()
);
}
}
Some(LogsCommand::Export {
kind,
output,
force,
}) => {
if output.exists() && !force {
anyhow::bail!(
"{} already exists; pass --force to overwrite",
output.display()
);
}
ensure_logs_passphrase()?;
warn_log_view_access();
let output_contents = render_log_events(&audit_logs::decrypt_events(kind)?)?;
crate::fs_util::write_private_file(&output, output_contents.as_bytes())?;
println!("Exported decrypted log {}", output.display());
}
Some(LogsCommand::Unlock { ttl }) => unlock_logs(&ttl)?,
None => match kind {
Some(kind) => println!("{}", audit_logs::log_path(kind).display()),
None => println!("{}", audit_logs::logs_dir().display()),
},
}
Ok(())
}
fn render_log_events(events: &[Value]) -> Result<String> {
let mut lines = Vec::with_capacity(events.len());
for event in events {
lines.push(serde_json::to_string(event)?);
}
Ok(lines.join("\n"))
}
fn edit() -> Result<()> {
let cwd = env::current_dir()?;
let passphrase = vault::read_existing_passphrase()?;
let resolved = registry::resolve_project_with_passphrase(None, &cwd, &passphrase)?;
with_passphrase_vault_access(&resolved, &passphrase, || {
vault::edit_vault_file(&resolved.vault, &passphrase)
})?;
let event = VaultEditEvent {
event_type: "vault.edit",
project: &resolved.name,
vault: &resolved.vault,
};
audit_logs::append_event(LogKind::Sessions, event)?;
println!("Updated encrypted vault.");
Ok(())
}
pub(crate) fn create_run_unlock_session(
project: &str,
vault_path: &Path,
passphrase: &str,
ttl: &str,
mode: Option<&str>,
) -> Result<unlock::UnlockSession> {
let ttl = unlock::parse_ttl(ttl)?;
let broker_result = broker::unlock_project_with_mode(
project,
vault_path,
passphrase,
ttl,
mode.map(str::to_string),
);
let validated = match broker_result {
Ok(()) => true,
Err(error) if broker_unavailable_error(&error) => {
vault::decrypt_vault_file(vault_path, passphrase).is_ok()
}
Err(error) => {
let error_message = error.to_string();
let event = VaultUnlockEvent {
event_type: "vault.unlock",
status: "failure",
project,
vault: vault_path,
error: Some(&error_message),
expires_at: None,
};
audit_logs::append_event(LogKind::Sessions, event)?;
anyhow::bail!("{error_message}");
}
};
if validated {
let session = if let Some(mode_name) = mode {
unlock::create_mode_unlock(project, vault_path, passphrase, ttl, mode_name)?
} else {
unlock::create_run_unlock(project, vault_path, passphrase, ttl)?
};
let event = VaultUnlockEvent {
event_type: "vault.unlock",
status: "success",
project,
vault: vault_path,
error: None,
expires_at: Some(session.expires_at.to_rfc3339()),
};
audit_logs::append_event(LogKind::Sessions, event)?;
Ok(session)
} else {
let error_message = "failed to decrypt vault; passphrase may be incorrect".to_string();
let event = VaultUnlockEvent {
event_type: "vault.unlock",
status: "failure",
project,
vault: vault_path,
error: Some(&error_message),
expires_at: None,
};
audit_logs::append_event(LogKind::Sessions, event)?;
anyhow::bail!("{error_message}")
}
}
fn broker_unavailable_error(error: &anyhow::Error) -> bool {
let message = error.to_string();
message.contains("Ward broker is unavailable")
|| message.contains("failed to connect to Ward broker")
|| message.contains("failed to start Ward broker")
|| message.contains("Ward broker did not become ready")
}
fn unlock_vault(ttl: &str, mode: Option<&str>) -> Result<()> {
let cwd = env::current_dir()?;
let passphrase = vault::read_existing_passphrase()?;
let resolved = registry::resolve_project_with_passphrase(None, &cwd, &passphrase)?;
registry::update_project_vault(
&resolved.name,
resolved.path.clone(),
resolved.vault.clone(),
)?;
let session =
create_run_unlock_session(&resolved.name, &resolved.vault, &passphrase, ttl, mode)?;
if let Some(mode_name) = mode {
println!(
"Vault unlocked with mode '{}' until {}.",
mode_name,
session.expires_at.to_rfc3339()
);
} else {
println!("Vault unlocked until {}.", session.expires_at.to_rfc3339());
}
Ok(())
}
fn lock() -> Result<()> {
if crate::human::is_human_terminal() {
let _ = crate::human::send_guardian_shutdown();
}
let revoked = grants::revoke_session_grants()?;
let cleared_unlocks = unlock::clear_all_unlocks()?;
broker::stop()?;
let event = VaultLockEvent {
event_type: "vault.lock",
revoked_session_grants: revoked,
cleared_unlock_sessions: cleared_unlocks,
};
audit_logs::append_event(LogKind::Sessions, event)?;
println!("Revoked {revoked} session grant(s).");
println!("Cleared {cleared_unlocks} unlock session(s).");
if crate::human::is_human_terminal() {
crate::human::display::print_padlock_closing();
}
Ok(())
}
fn rotate_vault(project_override: Option<&str>) -> Result<()> {
let cwd = env::current_dir()?;
let mut config = config::read_project_config(&cwd)?;
let project_name = project_override.unwrap_or(&config.project).to_string();
config.project = project_name.clone();
let passphrase = vault::read_existing_passphrase()?;
let old_vault = config::resolve_vault_path_with_passphrase(&cwd, &config, &passphrase);
anyhow::ensure!(
old_vault.exists(),
"vault not found at {}; unlock before rotating",
old_vault.display()
);
if broker::active_session_expiry(&project_name, &old_vault)?.is_some() {
broker::stop().context("failed to restore active broker session before rotation")?;
unlock::clear_project_unlocks(&project_name)?;
}
let plaintext = vault::decrypt_vault_file(&old_vault, &passphrase)?;
let new_vault = loop {
config.vault_nonce = vault::generate_vault_nonce();
let candidate = config::resolve_vault_path_dynamic(&cwd, &config, &passphrase);
if candidate != old_vault && !candidate.exists() {
break candidate;
}
};
let envelope = vault::encrypt_env(&plaintext, &passphrase)?;
vault::write_vault(&new_vault, &envelope)?;
fs::remove_file(&old_vault).context(format!(
"failed to remove old vault {}",
old_vault.display()
))?;
config::write_project_config(&cwd, &config, true)?;
registry::update_project_vault(&project_name, cwd.clone(), new_vault.clone())?;
env_file::refresh_locked_env(&cwd, &new_vault)?;
config::ensure_gitignore(&cwd, true)?;
println!("[ok] Vault rotated to {}", new_vault.display());
println!("[ok] .ward.json updated with new nonce.");
Ok(())
}
fn prompt_drag_drop_path() -> Result<std::path::PathBuf> {
use std::io::{self, BufRead};
eprint!(" Drag the recovery file here and press Enter: ");
let stdin = io::stdin();
let line = stdin
.lock()
.lines()
.next()
.ok_or_else(|| anyhow::anyhow!("no input"))??;
let raw = line.trim().trim_matches('\'').trim_matches('"').trim();
let path = if let Some(rest) = raw.strip_prefix("~/") {
dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?
.join(rest)
} else {
std::path::PathBuf::from(raw)
};
if !path.exists() {
anyhow::bail!("file not found: {}", path.display());
}
Ok(path)
}
fn recovery_command(command: RecoveryCommand) -> Result<()> {
match command {
RecoveryCommand::Export { output } => {
let cwd = env::current_dir()?;
let mut config = config::read_project_config(&cwd)?;
let passphrase = vault::read_existing_passphrase()?;
let dest = output.unwrap_or_else(|| {
dirs::desktop_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
});
let out_path = recovery::export_recovery_file(&config.project, &passphrase, &dest)?;
config.backup_exported = true;
config::write_project_config(&cwd, &config, true)?;
println!("[ok] Recovery file exported to {}", out_path.display());
println!(" Store this file somewhere safe (USB drive, secure cloud backup).");
println!(" You will need it and your vault passphrase to restore access.");
}
RecoveryCommand::Import { path } => {
let resolved_path = match path {
Some(p) => p,
None => prompt_drag_drop_path()?,
};
let dest = recovery::import_recovery_file(&resolved_path)?;
term::ok(&format!("Recovery file imported to {}", dest.display()));
}
RecoveryCommand::Create => {
let cwd = env::current_dir()?;
let mut config = config::read_project_config(&cwd)?;
let passphrase = vault::read_existing_passphrase()?;
let vault_path = config::resolve_vault_path_with_passphrase(&cwd, &config, &passphrase);
let plaintext = decrypt_vault_for_recovery(&config.project, &vault_path, &passphrase)?;
let real_path = recovery::create_recovery_files_with_material(
&config.project,
&passphrase,
&passphrase,
Some(&plaintext),
)?;
config.recovery_created = true;
config::write_project_config(&cwd, &config, true)?;
println!("[ok] Recovery file created at {}", real_path.display());
println!("[ok] Decoy files generated to prevent fingerprinting.");
println!(" Run `ward recovery export` to save a backup to a safe location.");
}
RecoveryCommand::Restore { path } => {
let cwd = env::current_dir()?;
let mut config = config::read_project_config(&cwd)?;
let passphrase = vault::read_existing_passphrase()?;
let vault_path = config::resolve_vault_path_with_passphrase(&cwd, &config, &passphrase);
if let Some(source) = path {
let recovery_file = recovery::import_recovery_file(&source)?;
recovery::restore_vault_from_recovery_file(
&config.project,
&vault_path,
&recovery_file,
&passphrase,
)?;
println!(
"[ok] Recovery file imported from {}",
recovery_file.display()
);
} else {
recovery::restore_vault_from_recovery(
&config.project,
&vault_path,
Some(&passphrase),
&passphrase,
)?;
}
config.recovery_created = true;
config::write_project_config(&cwd, &config, true)?;
env_file::refresh_locked_env(&cwd, &vault_path)?;
registry::update_project_vault(&config.project, cwd, vault_path.clone())?;
println!("[ok] Vault restored to {}", vault_path.display());
println!(" Run `ward unlock --ttl 8h` to start a fresh broker session.");
}
}
Ok(())
}
fn decrypt_vault_for_recovery(
project: &str,
vault_path: &Path,
passphrase: &str,
) -> Result<String> {
match vault::decrypt_vault_file(vault_path, passphrase) {
Ok(plaintext) => Ok(plaintext),
Err(first_error) => {
if broker::active_session_expiry(project, vault_path)?.is_some() {
broker::stop()?;
return vault::decrypt_vault_file(vault_path, passphrase)
.context("failed to decrypt vault after stopping the active broker session");
}
Err(first_error)
}
}
}
fn prompt_shell_reload(_rc: &Path) {
#[cfg(not(coverage))]
{
let reload = inquire::Confirm::new("Reload shell now to activate ward hooks?")
.with_default(true)
.prompt()
.unwrap_or(false);
if reload {
let marker = broker::run_dir().join("shell-reload");
crate::fs_util::ensure_private_dir(&broker::run_dir()).ok();
fs::write(&marker, "").ok();
println!();
println!(" Run this to reload and activate:");
println!(" exec $SHELL && ward human");
} else {
println!();
println!(" When ready, reload your shell:");
println!(" exec $SHELL && ward human");
}
}
#[cfg(coverage)]
{
let _ = rc;
println!();
println!(" Reload your shell and activate human mode:");
println!(" exec $SHELL && ward human");
}
}
fn ensure_shell_integration() -> Option<PathBuf> {
let shell = detect_shell()?;
let rc_path = shell_rc_path(&shell)?;
let contents = fs::read_to_string(&rc_path).unwrap_or_default();
let updated = install_shell_integration_contents(&shell, &contents);
if updated == contents {
return None;
}
if fs::write(&rc_path, updated).is_ok() {
Some(rc_path)
} else {
None
}
}
fn install_shell_integration_contents(shell: &str, contents: &str) -> String {
let mut updated = strip_ward_shell_integration(contents)
.trim_end_matches('\n')
.to_string();
if !updated.is_empty() {
updated.push_str("\n\n");
}
if !shell_path_present(&updated, shell) {
updated.push_str(shell_path_snippet(shell));
updated.push('\n');
}
updated.push_str(shell_integration_snippet(shell));
updated
}
fn strip_ward_shell_integration(contents: &str) -> String {
let lines = contents.lines().collect::<Vec<_>>();
let mut retained = Vec::with_capacity(lines.len());
let mut index = 0;
while index < lines.len() {
if lines[index].trim() == "# ward shell integration" {
index += 1;
if index < lines.len() {
let next = lines[index].trim();
if next == "if command -v ward >/dev/null 2>&1; then" || next == "if type -q ward" {
index += 1;
while index < lines.len()
&& lines[index].trim() != "fi"
&& lines[index].trim() != "end"
{
index += 1;
}
if index < lines.len() {
index += 1;
}
} else if next == "eval \"$(ward shell-init)\""
|| next == "ward shell-init | source"
{
index += 1;
}
}
while index < lines.len() && lines[index].trim().is_empty() {
index += 1;
}
continue;
}
retained.push(lines[index]);
index += 1;
}
let mut stripped = retained.join("\n");
if contents.ends_with('\n') && !stripped.is_empty() {
stripped.push('\n');
}
stripped
}
fn shell_path_present(contents: &str, shell: &str) -> bool {
if shell == "fish" {
contents.contains(".cargo/bin")
} else {
contents.contains("export PATH=\"$HOME/.cargo/bin:$PATH\"")
|| contents.contains("export PATH=\"$HOME/.cargo/bin:${PATH}\"")
|| contents.contains("export PATH=$HOME/.cargo/bin:$PATH")
}
}
fn shell_path_snippet(shell: &str) -> &'static str {
if shell == "fish" {
"# Added by ward installer\nfish_add_path \"$HOME/.cargo/bin\"\n"
} else {
"# Added by ward installer\nexport PATH=\"$HOME/.cargo/bin:$PATH\"\n"
}
}
fn shell_integration_snippet(shell: &str) -> &'static str {
if shell == "fish" {
"# ward shell integration\nif type -q ward\n set -gx WARD_SHELL_INTEGRATION 1\n ward shell-init | source\nend\n"
} else {
"# ward shell integration\nif command -v ward >/dev/null 2>&1; then\n export WARD_SHELL_INTEGRATION=1\n eval \"$(ward shell-init)\"\nfi\n"
}
}
fn shell_rc_path(shell: &str) -> Option<PathBuf> {
let home = dirs::home_dir()?;
let path = match shell {
"zsh" => home.join(".zshrc"),
"bash" => {
let candidate = home.join(".bashrc");
if candidate.exists() {
candidate
} else {
home.join(".bash_profile")
}
}
"fish" => home.join(".config").join("fish").join("config.fish"),
_ => return None,
};
Some(path)
}
fn shell_init(shell_override: Option<&str>) -> Result<()> {
let shell = shell_override
.map(str::to_string)
.or_else(detect_shell)
.unwrap_or_else(|| "sh".to_string());
print!("{}", shell_init_code(&shell));
Ok(())
}
fn detect_shell() -> Option<String> {
std::env::var("SHELL").ok().and_then(|s| {
std::path::Path::new(&s)
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
})
}
fn collect_command_prefixes(cwd: &Path) -> Vec<String> {
let mut prefixes: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for prefix in default_human_wrapped_commands() {
prefixes.insert(prefix.to_string());
}
if let Ok(project_config) = config::read_project_config(cwd) {
for profile in project_config.profiles.values() {
if let Some(prefix) = profile.command.split_whitespace().next() {
prefixes.insert(prefix.to_string());
}
}
for preset in &project_config.presets {
for cmd in &preset.match_commands {
if let Some(prefix) = cmd.split_whitespace().next() {
prefixes.insert(prefix.to_string());
}
}
}
}
if let Ok(mode_configs) = modes::load_local_modes(cwd) {
for mode in &mode_configs {
for cmd in &mode.allowed_commands {
if let Some(raw) = cmd.split_whitespace().next() {
let prefix = raw.trim_matches('*').trim_matches('/');
if !prefix.is_empty() {
prefixes.insert(prefix.to_string());
}
}
}
}
}
prefixes
.into_iter()
.filter(|p| is_safe_shell_function_name(p))
.collect()
}
fn default_human_wrapped_commands() -> &'static [&'static str] {
&[
"bun",
"cargo",
"deno",
"dotenv",
"drizzle-kit",
"next",
"node",
"npm",
"npx",
"pnpm",
"prisma",
"tsx",
"ts-node",
"vite",
"yarn",
]
}
fn is_safe_shell_function_name(name: &str) -> bool {
if name == "ward" {
return false;
}
const BUILTINS: &[&str] = &[
"cd", "echo", "export", "source", ".", "exec", "exit", "set", "unset", "alias", "eval",
"read", "printf", "test", "[", "[[", "true", "false", "return", "break", "continue",
"shift", "trap",
];
if BUILTINS.iter().any(|b| *b == name) {
return false;
}
name.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
&& !name.starts_with('-')
}
fn shell_init_code(shell: &str) -> String {
let ward_home = audit_logs::ward_home();
let sock_path = ward_home
.join("run")
.join("human-$$/guardian.sock")
.display()
.to_string();
let cwd = env::current_dir().unwrap_or_default();
let cmds = collect_command_prefixes(&cwd);
let cmds_ref: Vec<&str> = cmds.iter().map(String::as_str).collect();
if shell == "fish" {
fish_init_code(&ward_home, &cmds_ref)
} else {
posix_init_code(shell, &ward_home, &sock_path, &cmds_ref)
}
}
fn posix_init_code(
shell: &str,
ward_home: &std::path::Path,
sock_path: &str,
cmds: &[&str],
) -> String {
let mut out =
String::from("# ward shell integration — only active in human mode inside ward projects\n");
out.push_str("export WARD_SHELL_INTEGRATION=1\n");
out.push_str("__ward_project_root() {\n");
out.push_str(" __ward_dir=\"$PWD\"\n");
out.push_str(" while [ -n \"$__ward_dir\" ]; do\n");
out.push_str(" if [ -f \"$__ward_dir/.ward.json\" ]; then\n");
out.push_str(" printf '%s\\n' \"$__ward_dir\"\n");
out.push_str(" return 0\n");
out.push_str(" fi\n");
out.push_str(" if [ \"$__ward_dir\" = \"/\" ]; then\n");
out.push_str(" break\n");
out.push_str(" fi\n");
out.push_str(" __ward_dir=$(dirname \"$__ward_dir\")\n");
out.push_str(" done\n");
out.push_str(" return 1\n");
out.push_str("}\n");
out.push_str("__ward_wrap() {\n");
out.push_str(" __ward_root=\"$(__ward_project_root)\"\n");
out.push_str(" if [ -z \"$__ward_root\" ]; then\n");
out.push_str(" command \"$@\"\n");
out.push_str(" return $?\n");
out.push_str(" fi\n");
out.push_str(&format!(" if [ -S \"{sock_path}\" ]; then\n"));
out.push_str(" WARD_HUMAN_SHELL_PID=$$ command ward run -- \"$@\"\n");
out.push_str(" return $?\n");
out.push_str(" fi\n");
out.push_str(
" printf '%s\\n' 'Ward human mode is not active for this terminal; run ward human' >&2\n",
);
out.push_str(" printf '%s\\n' \"shell pid: $$\" >&2\n");
out.push_str(&format!(
" printf '%s\\n' \"expected guardian: {sock_path}\" >&2\n"
));
out.push_str(" return 126\n");
out.push_str("}\n");
if shell == "zsh" {
out.push_str(&zsh_prompt_badge_code(sock_path));
}
let reload_marker = ward_home
.join("run")
.join("shell-reload")
.display()
.to_string();
out.push_str("ward() {\n");
out.push_str(" WARD_HUMAN_SHELL_PID=$$ command ward \"$@\"\n");
out.push_str(" __ward_exit=$?\n");
out.push_str(" case \"$1\" in\n");
out.push_str(" setup|init)\n");
out.push_str(&format!(" if [ -f \"{reload_marker}\" ]; then\n"));
out.push_str(&format!(" rm -f \"{reload_marker}\"\n"));
out.push_str(" exec $SHELL\n");
out.push_str(" fi\n");
out.push_str(" ;;\n");
out.push_str(" esac\n");
out.push_str(" return $__ward_exit\n");
out.push_str("}\n");
for cmd in cmds {
out.push_str(&format!("{cmd}() {{ __ward_wrap {cmd} \"$@\"; }}\n"));
}
out.push_str("command_not_found_handle() { __ward_wrap \"$@\"; }\n");
out.push_str("command_not_found_handler() { __ward_wrap \"$@\"; }\n");
out
}
fn zsh_prompt_badge_code(sock_path: &str) -> String {
let mut out = String::new();
out.push_str("if [ -n \"${ZSH_VERSION:-}\" ]; then\n");
out.push_str("__ward_prompt_badge() {\n");
out.push_str(" __ward_root=\"$(__ward_project_root)\"\n");
out.push_str(" if [ -z \"$__ward_root\" ]; then\n");
out.push_str(" return 0\n");
out.push_str(" fi\n");
out.push_str(&format!(" if [ -S \"{sock_path}\" ]; then\n"));
out.push_str(" printf '%s' 'ward:human'\n");
out.push_str(" else\n");
out.push_str(" printf '%s' 'ward:locked'\n");
out.push_str(" fi\n");
out.push_str("}\n");
out.push_str("__ward_prompt_without_badge() {\n");
out.push_str(" __ward_prompt=\"${1:-}\"\n");
out.push_str(" __ward_prompt=\"${__ward_prompt// ward:human/}\"\n");
out.push_str(" __ward_prompt=\"${__ward_prompt//ward:human/}\"\n");
out.push_str(" __ward_prompt=\"${__ward_prompt// ward:locked/}\"\n");
out.push_str(" __ward_prompt=\"${__ward_prompt//ward:locked/}\"\n");
out.push_str(" printf '%s' \"$__ward_prompt\"\n");
out.push_str("}\n");
out.push_str("__ward_precmd() {\n");
out.push_str(" __ward_badge=\"$(__ward_prompt_badge)\"\n");
out.push_str(" RPROMPT=\"$(__ward_prompt_without_badge \"${RPROMPT:-}\")\"\n");
out.push_str(" if [ -n \"$__ward_badge\" ]; then\n");
out.push_str(" if [ -n \"$RPROMPT\" ]; then\n");
out.push_str(" RPROMPT=\"$RPROMPT $__ward_badge\"\n");
out.push_str(" else\n");
out.push_str(" RPROMPT=\"$__ward_badge\"\n");
out.push_str(" fi\n");
out.push_str(" fi\n");
out.push_str("}\n");
out.push_str("if ! (( ${precmd_functions[(I)__ward_precmd]} )); then\n");
out.push_str(" precmd_functions+=(__ward_precmd)\n");
out.push_str("fi\n");
out.push_str("__ward_precmd\n");
out.push_str("fi\n");
out
}
fn fish_init_code(ward_home: &std::path::Path, cmds: &[&str]) -> String {
let sock_dir = ward_home.join("run").display().to_string();
let mut out =
String::from("# ward shell integration — only active in human mode inside ward projects\n");
out.push_str("set -gx WARD_SHELL_INTEGRATION 1\n");
out.push_str("function __ward_project_root\n");
out.push_str(" set dir (pwd)\n");
out.push_str(" while test -n \"$dir\"\n");
out.push_str(" if test -f \"$dir/.ward.json\"\n");
out.push_str(" echo $dir\n");
out.push_str(" return 0\n");
out.push_str(" end\n");
out.push_str(" if test \"$dir\" = \"/\"\n");
out.push_str(" break\n");
out.push_str(" end\n");
out.push_str(" set dir (dirname \"$dir\")\n");
out.push_str(" end\n");
out.push_str(" return 1\n");
out.push_str("end\n");
out.push_str("function __ward_wrap\n");
out.push_str(&format!(
" set sock \"{sock_dir}/human-$fish_pid/guardian.sock\"\n"
));
out.push_str(" set project_root (__ward_project_root)\n");
out.push_str(" if test -z \"$project_root\"\n");
out.push_str(" command $argv\n");
out.push_str(" return $status\n");
out.push_str(" end\n");
out.push_str(" if test -S $sock\n");
out.push_str(" env WARD_HUMAN_SHELL_PID=$fish_pid command ward run -- $argv\n");
out.push_str(" return $status\n");
out.push_str(" else\n");
out.push_str(
" echo 'Ward human mode is not active for this terminal; run ward human' >&2\n",
);
out.push_str(" echo \"shell pid: $fish_pid\" >&2\n");
out.push_str(" echo \"expected guardian: $sock\" >&2\n");
out.push_str(" return 126\n");
out.push_str(" end\n");
out.push_str("end\n");
out.push_str("function ward\n");
out.push_str(" env WARD_HUMAN_SHELL_PID=$fish_pid command ward $argv\n");
out.push_str(" set __ward_exit $status\n");
out.push_str(" if contains -- $argv[1] setup init\n");
out.push_str(" source ~/.config/fish/config.fish 2>/dev/null\n");
out.push_str(" end\n");
out.push_str(" return $__ward_exit\n");
out.push_str("end\n");
for cmd in cmds {
out.push_str(&format!("function {cmd}; __ward_wrap {cmd} $argv; end\n"));
}
out.push_str("function __ward_command_not_found --on-event fish_command_not_found\n");
out.push_str(" __ward_wrap $argv\n");
out.push_str("end\n");
out
}
fn modes_command(command: ModesCommand) -> Result<()> {
match command {
ModesCommand::List => {
let cwd = env::current_dir()?;
let resolved = registry::resolve_project(None, &cwd)?;
let modes = modes::load_local_modes(&resolved.path)?;
if modes.is_empty() {
println!("No modes defined in .ward.modes.json");
} else {
for mode in &modes {
println!(
"{} ({})",
mode.name,
serde_json::to_string(&mode.level)
.unwrap_or_default()
.trim_matches('"')
);
}
}
Ok(())
}
ModesCommand::Push { project, .. } => {
let cwd = env::current_dir()?;
let initial = registry::resolve_project(project.as_deref(), &cwd)?;
let modes_path = modes::local_modes_path(&initial.path);
let local_modes = modes::load_local_modes(&initial.path)?;
if local_modes.is_empty() {
anyhow::bail!("no modes found in {}", modes_path.display());
}
let passphrase = vault::read_existing_passphrase()?;
let resolved =
registry::resolve_project_with_passphrase(project.as_deref(), &cwd, &passphrase)?;
with_passphrase_vault_access(&resolved, &passphrase, || {
vault::decrypt_vault_file(&resolved.vault, &passphrase)
.context("invalid passphrase — cannot push modes")
})?;
modes::push_modes(&local_modes, &resolved.name, &passphrase, &modes_path)?;
println!(
"Pushed {} mode(s) for project '{}'.",
local_modes.len(),
resolved.name
);
Ok(())
}
ModesCommand::Status => {
let cwd = env::current_dir()?;
let resolved = registry::resolve_project(None, &cwd)?;
match broker::status() {
Ok(status) => {
let session = status.sessions.iter().find(|s| s.project == resolved.name);
match session.and_then(|s| s.active_mode.as_deref()) {
Some(mode_name) => println!("Active mode: {mode_name}"),
None => println!("No active mode for project '{}'.", resolved.name),
}
}
Err(_) => println!("Broker not running — no active mode."),
}
Ok(())
}
}
}
fn teardown(
project: Option<String>,
export_path: PathBuf,
yes: bool,
restore_env: bool,
) -> Result<()> {
if !yes {
anyhow::bail!("teardown requires --yes");
}
let cwd = env::current_dir()?;
let initial = registry::resolve_project(project.as_deref(), &cwd)?;
let export_path = if restore_env && export_path == PathBuf::from(".env.export") {
PathBuf::from(".env")
} else {
export_path
};
let output = project_relative_path(&initial.path, export_path);
if output == initial.path.join(".env") && !restore_env {
anyhow::bail!("restoring plaintext .env requires --restore-env");
}
if env::var_os("WARD_UNSAFE_TEST_PASSPHRASE").is_none() && !std::io::stdin().is_terminal() {
anyhow::bail!(
"teardown requires the vault PIN/passphrase; --yes only skips destructive confirmation and does not bypass secret export approval"
);
}
let passphrase = vault::read_existing_passphrase().context(
"teardown requires the vault PIN/passphrase even with --yes; run from an interactive terminal or set the unsafe test passphrase only in tests",
)?;
let resolved =
registry::resolve_project_with_passphrase(project.as_deref(), &cwd, &passphrase)?;
with_passphrase_vault_access(&resolved, &passphrase, || {
env_file::export_env_file(&output, &resolved.vault, &passphrase, true)
})?;
vault::validate_dotenv(&fs::read_to_string(&output)?)?;
let mut removed_files = Vec::new();
for path in [
resolved.path.join(config::PROJECT_CONFIG_FILE),
resolved.vault.clone(),
] {
remove_project_file_if_exists(&path, &mut removed_files)?;
}
let env_path = resolved.path.join(".env");
remove_locked_env_if_needed(&env_path, &output, &mut removed_files)?;
for path in [
resolved.path.join(config::AGENT_INSTRUCTIONS_FILE),
resolved.path.join(config::CLAUDE_INSTRUCTIONS_FILE),
] {
if remove_agent_instruction_section(&path)? {
removed_files.push(format!("updated {}", path.display()));
}
}
registry::remove_project(&resolved.name)?;
let removed_grants = grants::remove_project_grants(&resolved.name)?;
let removed_pending_requests = pending_requests::remove_project_requests(&resolved.name)?;
let cleared_unlock_sessions = unlock::clear_project_unlocks(&resolved.name)?;
let event = TeardownEvent {
event_type: "teardown.completed",
project: &resolved.name,
export_path: &output,
removed_files,
removed_grants,
removed_pending_requests,
cleared_unlock_sessions,
};
audit_logs::append_event(LogKind::Sessions, event)?;
println!("Exported plaintext env {}", output.display());
println!("Removed Ward project {}", resolved.name);
println!("Encrypted audit logs were preserved.");
Ok(())
}
fn remove_locked_env_if_needed(
env_path: &Path,
output: &Path,
removed_files: &mut Vec<String>,
) -> Result<()> {
let should_keep_env = env_path == output || !env_file::is_locked_env_file(env_path)?;
if should_keep_env {
return Ok(());
}
remove_project_file_if_exists(env_path, removed_files)
}
fn remove_project_file_if_exists(path: &Path, removed_files: &mut Vec<String>) -> Result<()> {
if !path.exists() {
return Ok(());
}
fs::remove_file(path).context(format!("failed to remove {}", path.display()))?;
removed_files.push(path.display().to_string());
Ok(())
}
fn remove_agent_instruction_section(path: &Path) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let contents =
fs::read_to_string(path).context(format!("failed to read {}", path.display()))?;
let Some(index) = contents.find("<!-- ward-agent-instructions -->") else {
return Ok(false);
};
let retained = contents[..index].trim_end();
if retained.is_empty() {
fs::remove_file(path).context(format!("failed to remove {}", path.display()))?;
} else {
fs::write(path, format!("{retained}\n"))
.context(format!("failed to write {}", path.display()))?;
}
Ok(true)
}
fn unlock_logs(ttl: &str) -> Result<()> {
let cwd = env::current_dir()?;
let passphrase = vault::read_existing_passphrase()?;
let resolved = registry::resolve_project_with_passphrase(None, &cwd, &passphrase)?;
with_passphrase_vault_access(&resolved, &passphrase, || {
vault::decrypt_vault_file(&resolved.vault, &passphrase)
})?;
let event = LogsUnlockEvent {
event_type: "logs.unlock",
project: &resolved.name,
vault: &resolved.vault,
expires_at: "deprecated-validate-only".to_string(),
};
audit_logs::append_event(LogKind::Sessions, event)?;
println!(
"Log passphrase validated. Note: ward logs unlock is deprecated; logs view/export prompts every time."
);
println!("Requested TTL {ttl} was ignored.");
Ok(())
}
fn ensure_logs_passphrase() -> Result<()> {
let cwd = env::current_dir()?;
let passphrase = vault::read_existing_passphrase()?;
let resolved = registry::resolve_project_with_passphrase(None, &cwd, &passphrase)?;
with_passphrase_vault_access(&resolved, &passphrase, || {
vault::decrypt_vault_file(&resolved.vault, &passphrase)
})?;
Ok(())
}
fn warn_log_view_access() {
eprintln!(
"Ward warning: decrypted logs are for review only. Edits are tamper-evident through the hash chain; deleted logs should be treated as a high-severity signal."
);
}
fn resolve_profile(
config: &config::ProjectConfig,
profile: Option<&str>,
action: Option<String>,
command: Option<String>,
env_names: Vec<String>,
) -> Result<ResolvedProfile> {
if let Some(profile_name) = profile {
if command.is_some() || !env_names.is_empty() {
anyhow::bail!("--profile cannot be combined with --command or --env");
}
let Some(profile) = config.profiles.get(profile_name) else {
anyhow::bail!("profile {profile_name} is not defined in .ward.json");
};
return Ok(ResolvedProfile {
command: profile.command.clone(),
command_args: split_profile_command(&profile.command),
env_names: profile.env.clone(),
action: action.or_else(|| Some(profile.action.clone())),
default_scope: profile.default_scope,
});
}
let command = command.context("--command is required unless --profile is used")?;
if env_names.is_empty() {
anyhow::bail!("at least one --env is required unless --profile is used");
}
Ok(ResolvedProfile {
command: command.clone(),
command_args: split_profile_command(&command),
env_names,
action,
default_scope: ApprovalScope::Once,
})
}
fn resolve_run_profile(
config: &config::ProjectConfig,
profile: Option<&str>,
action: Option<String>,
env_names: Vec<String>,
command: Vec<String>,
allow_empty_env: bool,
) -> Result<ResolvedProfile> {
if let Some(profile_name) = profile {
if !command.is_empty() || !env_names.is_empty() {
anyhow::bail!("--profile cannot be combined with explicit command args or --env");
}
let Some(profile) = config.profiles.get(profile_name) else {
anyhow::bail!("profile {profile_name} is not defined in .ward.json");
};
return Ok(ResolvedProfile {
command: profile.command.clone(),
command_args: split_profile_command(&profile.command),
env_names: profile.env.clone(),
action: action.or_else(|| Some(profile.action.clone())),
default_scope: profile.default_scope,
});
}
if command.is_empty() {
anyhow::bail!("command args are required unless --profile is used");
}
if env_names.is_empty() && !allow_empty_env {
anyhow::bail!("at least one --env is required unless --profile is used");
}
Ok(ResolvedProfile {
command: command.join(" "),
command_args: command,
env_names,
action,
default_scope: ApprovalScope::Once,
})
}
fn split_profile_command(command: &str) -> Vec<String> {
command
.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>()
}
fn effective_grant_id(
decision: &ApprovalDecision,
persisted_grant: Option<&grants::ApprovalGrant>,
) -> Option<uuid::Uuid> {
decision
.grant_id
.or_else(|| persisted_grant.map(|grant| grant.id))
}
fn grant_receipt_hash(
decision: &ApprovalDecision,
persisted_grant: Option<&grants::ApprovalGrant>,
) -> Result<Option<String>> {
if let Some(grant) = persisted_grant {
return Ok(grant
.receipt
.as_ref()
.map(|receipt| receipt.payload_hash.clone()));
}
let Some(grant_id) = decision.grant_id else {
return Ok(None);
};
Ok(grants::load_grants()?
.into_iter()
.find(|grant| grant.id == grant_id)
.and_then(|grant| grant.receipt.map(|receipt| receipt.payload_hash)))
}
fn log_anomaly_alerts(config: &config::ProjectConfig, grant_id: Option<uuid::Uuid>) -> Result<()> {
let Some(grant_id) = grant_id else {
return Ok(());
};
let events = audit_logs::decrypt_events(LogKind::Executions)?;
for alert in anomaly::detect_grant_anomalies(
&config.anomaly_detection,
&events,
grant_id,
chrono::Utc::now(),
) {
audit_logs::append_event(LogKind::Alerts, alert)?;
}
Ok(())
}
fn evaluate_access(
config: &config::ProjectConfig,
access: &AccessRequest,
) -> policy::PolicyEvaluation {
let findings =
detection::preflight_findings(&access.command, &access.env, access.action.as_deref());
policy::evaluate_request(config, access, None, findings)
}
fn approval_human_proof(source: approvals::ApprovalSource) -> Option<&'static str> {
match source {
approvals::ApprovalSource::AgentMediated => Some("external-agent-ui"),
approvals::ApprovalSource::LocalTty => Some("local-tty"),
approvals::ApprovalSource::ManualAllow => Some("local-cli"),
_ => None,
}
}
fn critical_confirmation_for_decision(
decision: &ApprovalDecision,
evaluation: &policy::PolicyEvaluation,
) -> bool {
decision.approved
&& decision.scope == ApprovalScope::Once
&& detection::has_critical_findings(&evaluation.findings)
}
fn handle_post_run_logging_result(exit_code: i32, result: Result<()>) -> Result<()> {
if let Err(error) = result {
eprintln!("Ward warning: post-run audit logging failed: {error}");
if exit_code == 0 {
anyhow::bail!("Ward post-run audit logging failed");
}
}
Ok(())
}
fn warn_anomaly_failure(result: Result<()>) {
if let Err(error) = result {
eprintln!("Ward warning: anomaly detection failed: {error}");
}
}
fn consume_once_grant_if_reused(decision: &ApprovalDecision) -> Result<()> {
let should_consume = decision.source == approvals::ApprovalSource::Grant
&& decision.scope == ApprovalScope::Once;
if !should_consume {
return Ok(());
}
let Some(grant_id) = decision.grant_id else {
return Ok(());
};
grants::consume_once_grant(grant_id).map(|_| ())
}
fn decide_access(
access: &AccessRequest,
evaluation: &policy::PolicyEvaluation,
allow_grants: bool,
) -> Result<ApprovalDecision> {
if evaluation.approval_mode == ApprovalMode::Deny {
return Ok(ApprovalDecision {
approved: false,
scope: ApprovalScope::Deny,
approved_env: Vec::new(),
denied_env: access.env.clone(),
source: approvals::ApprovalSource::PolicyDeny,
grant_id: None,
});
}
let critical = detection::has_critical_findings(&evaluation.findings);
let suspicious_action = detection::has_suspicious_action_findings(&evaluation.findings);
if allow_grants {
let grant = if critical {
grants::find_matching_once_grant(access, true)?
} else if suspicious_action {
grants::find_matching_non_always_grant(access)?
} else {
grants::find_matching_grant(access)?
};
if let Some(grant) = grant {
return Ok(grants::approval_from_grant(access, &grant));
}
}
if evaluation.requires_prompt {
approvals::prompt_for_approval(access, evaluation)
} else {
Ok(approvals::auto_approval(evaluation))
}
}
fn non_interactive_decision(
access: &AccessRequest,
evaluation: &policy::PolicyEvaluation,
) -> Result<Option<ApprovalDecision>> {
if evaluation.approval_mode == ApprovalMode::Deny {
return Ok(Some(ApprovalDecision {
approved: false,
scope: ApprovalScope::Deny,
approved_env: Vec::new(),
denied_env: access.env.clone(),
source: approvals::ApprovalSource::PolicyDeny,
grant_id: None,
}));
}
let critical = detection::has_critical_findings(&evaluation.findings);
let suspicious_action = detection::has_suspicious_action_findings(&evaluation.findings);
let grant = if critical {
grants::find_matching_once_grant(access, true)?
} else if suspicious_action {
grants::find_matching_non_always_grant(access)?
} else {
grants::find_matching_grant(access)?
};
if let Some(grant) = grant {
return Ok(Some(grants::approval_from_grant(access, &grant)));
}
if evaluation.requires_prompt {
return Ok(None);
}
Ok(Some(approvals::auto_approval(evaluation)))
}
fn non_interactive_decision_with_context(
access: &AccessRequest,
evaluation: &policy::PolicyEvaluation,
verified_context: Option<&context::VerifiedContext>,
) -> Result<Option<ApprovalDecision>> {
let Some(verified_context) = verified_context else {
return non_interactive_decision(access, evaluation);
};
if evaluation.approval_mode == ApprovalMode::Deny {
return Ok(Some(ApprovalDecision {
approved: false,
scope: ApprovalScope::Deny,
approved_env: Vec::new(),
denied_env: access.env.clone(),
source: approvals::ApprovalSource::PolicyDeny,
grant_id: None,
}));
}
let critical = detection::has_critical_findings(&evaluation.findings);
let suspicious_action = detection::has_suspicious_action_findings(&evaluation.findings);
let grant = if critical {
grants::find_matching_once_grant_with_context(access, true, verified_context)?
} else if suspicious_action {
grants::find_matching_non_always_grant_with_context(access, verified_context)?
} else {
grants::find_matching_grant_with_context(access, verified_context)?
};
if let Some(grant) = grant {
return Ok(Some(grants::approval_from_grant(access, &grant)));
}
if evaluation.requires_prompt {
return Ok(None);
}
Ok(Some(approvals::auto_approval(evaluation)))
}
fn create_run_pending_request(
access: &AccessRequest,
evaluation: &policy::PolicyEvaluation,
git: &git_context::GitContext,
verified_context: Option<context::VerifiedContext>,
) -> Result<pending_requests::PendingRequest> {
pending_requests::create_pending_request_with_context(
access.clone(),
evaluation.clone(),
git.clone(),
verified_context,
)
}
fn print_run_approval_required(pending: &pending_requests::PendingRequest) -> Result<()> {
let response = RunApprovalRequiredResponse {
status: "approval_required",
unlock_required: false,
request: pending_requests::response_for(pending),
};
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(())
}
fn print_run_unlock_required(
access: &AccessRequest,
evaluation: &policy::PolicyEvaluation,
unlock_reason: Option<&str>,
) -> Result<()> {
let response = RunUnlockRequiredResponse {
status: "unlock_required",
approval_required: false,
unlock_required: true,
unlock_reason,
project: &access.project,
command: &access.command,
env: &access.env,
findings: &evaluation.findings,
risk: run_risk_summary(evaluation),
unlock_command: "ward unlock --ttl 8h",
};
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(())
}
fn print_run_vault_key_missing(
access: &AccessRequest,
evaluation: &policy::PolicyEvaluation,
missing_env: Vec<String>,
) -> Result<()> {
let response = RunVaultKeyMissingResponse {
status: "vault_key_missing",
approval_required: false,
unlock_required: false,
project: &access.project,
command: &access.command,
env: &access.env,
missing_env,
findings: &evaluation.findings,
risk: run_risk_summary(evaluation),
message: "One or more approved env vars are not present in the vault.",
remediation: "Update .ward.json to request only vault-present keys, or run ward env unlock, add the missing key, then ward env lock.",
};
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(())
}
fn print_run_denied(access: &AccessRequest, evaluation: &policy::PolicyEvaluation) -> Result<()> {
let response = RunDeniedResponse {
status: "denied",
approval_required: false,
unlock_required: false,
project: &access.project,
command: &access.command,
env: &access.env,
findings: &evaluation.findings,
risk: run_risk_summary(evaluation),
};
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(())
}
fn broker_vault_key_missing_envs(error: &anyhow::Error) -> Option<Vec<String>> {
let broker_error = error.downcast_ref::<broker::BrokerError>()?;
if broker_error.reason() != "vault_key_missing" {
return None;
}
Some(
broker_error
.message()
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect(),
)
}
fn run_risk_summary(evaluation: &policy::PolicyEvaluation) -> String {
if detection::has_critical_findings(&evaluation.findings) {
"critical".to_string()
} else if !evaluation.findings.is_empty() || !evaluation.denied_env.is_empty() {
"warning".to_string()
} else {
"low".to_string()
}
}
#[cfg(any(test, coverage))]
fn marker(ok: bool) -> &'static str {
if ok {
"[ok]"
} else {
"!"
}
}
#[cfg(any(test, coverage))]
fn grant_integrity_messages(unsigned: usize, invalid: usize) -> Vec<String> {
let mut messages = Vec::new();
if unsigned == 0 && invalid == 0 {
messages.push("[ok] Approval grants are signed and valid.".to_string());
}
if unsigned > 0 {
messages.push(format!(
"! Legacy unsigned approval grants: {unsigned}. Re-approve them."
));
}
if invalid > 0 {
messages.push(format!(
"! Invalid signed approval grants: {invalid}. Revoke and re-approve them."
));
}
messages
}
fn grant_status_label(status: grants::GrantIntegrityStatus) -> &'static str {
match status {
grants::GrantIntegrityStatus::Valid => "valid-signed",
grants::GrantIntegrityStatus::Expired => "expired",
grants::GrantIntegrityStatus::LegacyUnsigned => "legacy-unsigned",
grants::GrantIntegrityStatus::Invalid => "invalid-signature",
}
}
fn verified_agent_key_id(context: Option<&context::VerifiedContext>) -> Option<&str> {
match context {
Some(context) => Some(context.agent_key_id.as_str()),
None => None,
}
}
#[cfg(all(coverage, not(test)))]
#[doc(hidden)]
pub fn coverage_exercise_cli_edges() -> Result<()> {
let old_cwd = env::current_dir()?;
let home = tempfile::tempdir()?;
env::set_var("WARD_HOME", home.path());
env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
let project = tempfile::tempdir()?;
env::set_current_dir(project.path())?;
fs::write(project.path().join(".gitignore"), ".env\n.env.*\n")?;
let project_env = project.path().join(".env");
fs::write(&project_env, "DATABASE_URL=postgres://coverage\n")?;
let main_setup = SetupOptions {
yes: true,
project: Some("coverage-main".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
};
setup(main_setup)?;
dispatch(Cli {
command: Commands::Broker {
command: BrokerCommand::SocketPath,
},
})
.expect("coverage broker socket dispatch should succeed");
dispatch(Cli {
command: Commands::Worktrees {
command: WorktreesCommand::List {
project: "coverage-main".to_string(),
},
},
})
.expect("coverage worktree list dispatch should succeed");
broker_command(BrokerCommand::SocketPath)?;
broker_command(BrokerCommand::Status)?;
broker_command(BrokerCommand::Stop)?;
let allowed_root = tempfile::tempdir()?;
worktrees_command(WorktreesCommand::AllowRoot {
project: "coverage-main".to_string(),
path: allowed_root.path().to_path_buf(),
})
.expect("coverage allow-root should succeed");
worktrees_command(WorktreesCommand::List {
project: "coverage-main".to_string(),
})
.expect("coverage list should succeed");
worktrees_command(WorktreesCommand::RemoveRoot {
project: "coverage-main".to_string(),
path: allowed_root.path().to_path_buf(),
})
.expect("coverage remove-root should succeed");
worktrees_command(WorktreesCommand::RemoveRoot {
project: "coverage-main".to_string(),
path: allowed_root.path().to_path_buf(),
})
.expect("coverage missing remove-root should succeed");
worktrees_command(WorktreesCommand::Approve {
request_id: uuid::Uuid::new_v4(),
json: false,
})
.expect("coverage missing worktree approval should succeed");
worktrees_command(WorktreesCommand::Deny {
request_id: uuid::Uuid::new_v4(),
json: false,
})
.expect("coverage missing worktree denial should succeed");
let absolute_export = EnvCommand::Export {
project: None,
output: Some(project.path().join(".env.absolute.export")),
force: false,
unsafe_stdout: false,
};
env_command(absolute_export)?;
let stdout_export = EnvCommand::Export {
project: None,
output: None,
force: false,
unsafe_stdout: true,
};
env_command(stdout_export)?;
let access = AccessRequest {
project: "coverage-main".to_string(),
agent: Some("codex".to_string()),
branch: None,
action: Some("Coverage".to_string()),
command: "sh -c true".to_string(),
env: vec!["DATABASE_URL".to_string()],
};
let clean = policy::PolicyEvaluation {
matched_profile: None,
matched_preset: None,
approval_mode: ApprovalMode::Auto,
requested_env: access.env.clone(),
approved_env: access.env.clone(),
denied_env: Vec::new(),
requires_prompt: false,
findings: Vec::new(),
};
let mut critical = clean.clone();
critical.findings.push(detection::Finding::critical(
"critical.coverage",
"critical coverage finding",
));
critical.requires_prompt = true;
let _ = decide_access(&access, &clean, true)?;
let _ = non_interactive_decision(&access, &clean)?;
let mut denied = clean.clone();
denied.approval_mode = ApprovalMode::Deny;
let _ = non_interactive_decision(&access, &denied)?;
let _ = non_interactive_decision(&access, &critical)?;
let _ = run_risk_summary(&critical);
let mut suspicious = clean.clone();
suspicious.findings.push(detection::Finding::warning(
"action.prompt_injection",
"coverage suspicious action finding",
));
suspicious.requires_prompt = true;
let _ = decide_access(&access, &suspicious, true)?;
let _ = signing_lookup_message(Ok(unlock::RunSigningLookup::Missing));
let _ = signing_lookup_message(Ok(unlock::RunSigningLookup::MaterialUnavailable {
reason: "coverage".to_string(),
}));
let _ = signing_lookup_message(Err(anyhow::anyhow!("coverage")));
let _ = grant_integrity_messages(1, 1);
let _ = grant_status_label(grants::GrantIntegrityStatus::Expired);
let _ = grant_status_label(grants::GrantIntegrityStatus::LegacyUnsigned);
let _ = grant_status_label(grants::GrantIntegrityStatus::Invalid);
let resolved_main = registry::resolve_project(Some("coverage-main"), project.path())?;
let missing_context = AgentContextOptions {
agent: None,
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: None,
};
assert!(
verified_no_prompt_context(project.path(), &resolved_main, &missing_context)?.is_none()
);
request(
None,
missing_context.clone(),
Some("Coverage missing context".to_string()),
Some("sh -c true".to_string()),
access.env.clone(),
true,
true,
)
.expect("coverage missing-context request should return structured JSON");
let wrong_key_context = AgentContextOptions {
agent: Some("codex".to_string()),
agent_key_id: Some("agent:wrong".to_string()),
worktree: None,
git_remote: None,
commit: None,
branch: None,
};
assert!(
verified_no_prompt_context(project.path(), &resolved_main, &wrong_key_context)?.is_none()
);
let agent = agents::ensure_agent("coverage-main", "codex")?;
let proof = agents::sign_payload("coverage-main", "codex", "coverage payload")?;
let _ = agents::verify_proof("coverage-main", &proof)?;
let _ = context::normalize_remote("https://example.test/demo.git/");
let mut agent_state = agents::load_agents()?;
let agent_record = agent_state
.projects
.get_mut("coverage-main")
.and_then(|agents| agents.iter_mut().find(|agent| agent.agent_name == "codex"))
.expect("coverage agent should exist");
agent_record.private_seed = "AQID".to_string();
agents::save_agents(&agent_state)?;
assert!(agents::sign_payload("coverage-main", "codex", "coverage payload").is_err());
let mut agent_state = agents::load_agents()?;
let agent_record = agent_state
.projects
.get_mut("coverage-main")
.and_then(|agents| agents.iter_mut().find(|agent| agent.agent_name == "codex"))
.expect("coverage agent should exist");
agent_record.public_key = "AQID".to_string();
agents::save_agents(&agent_state)?;
assert!(agents::verify_proof("coverage-main", &proof).is_err());
let matching_key_context = AgentContextOptions {
agent: Some("codex".to_string()),
agent_key_id: Some(agent.agent_key_id),
worktree: None,
git_remote: None,
commit: None,
branch: None,
};
assert!(
verified_no_prompt_context(project.path(), &resolved_main, &matching_key_context)?
.is_none()
);
let no_git_claim = context::ClaimedContext {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: Some(project.path().to_path_buf()),
branch: Some("main".to_string()),
git_remote: Some("https://example.test/demo.git".to_string()),
commit: Some("abc".to_string()),
};
assert!(context::verify_no_prompt_context(
&no_git_claim,
project.path(),
&resolved_main,
"agent:coverage".to_string(),
)
.is_err());
let verified_worktree = |path: PathBuf, remote: &str| context::VerifiedContext {
project: "coverage-main".to_string(),
agent: "codex".to_string(),
agent_key_id: "agent:coverage".to_string(),
worktree: path,
branch: "main".to_string(),
git_remote: remote.to_string(),
commit: "abc123".to_string(),
git_common_dir: None,
};
let unregistered = registry::ResolvedProject {
name: "coverage-unregistered".to_string(),
path: project.path().to_path_buf(),
vault: project.path().join(".env.vault"),
};
let unregistered_context =
verified_worktree(project.path().to_path_buf(), "https://example.test/demo");
let unregistered_allowed =
enforce_worktree_for_no_prompt(&unregistered, &unregistered_context)?;
assert!(unregistered_allowed);
let autobind_root = tempfile::tempdir()?;
let autobind_worktree = autobind_root.path().join("agent-wt");
fs::create_dir(&autobind_worktree)?;
worktrees::allow_root("coverage-main", autobind_root.path())?;
let missing_root = home.path().join("missing-root");
let missing_worktree = missing_root.join("child");
let _ = worktrees::allow_root("coverage-main", &missing_root)?;
let autobind_context = verified_worktree(autobind_worktree, "https://example.test/demo");
let autobind_allowed = enforce_worktree_for_no_prompt(&resolved_main, &autobind_context)?;
assert!(autobind_allowed);
let missing_autobind_context = verified_worktree(missing_worktree, "https://example.test/demo");
let _ = enforce_worktree_for_no_prompt(&resolved_main, &missing_autobind_context)?;
let _ = worktrees::remove_root("coverage-main", &missing_root)?;
let approval_worktree = tempfile::tempdir()?;
let approval_context = verified_worktree(
approval_worktree.path().to_path_buf(),
"https://example.test/demo",
);
let approval_allowed = enforce_worktree_for_no_prompt(&resolved_main, &approval_context)?;
assert!(!approval_allowed);
let registered_for_worktree = registry::load_registry()?
.projects
.get("coverage-main")
.cloned()
.context("coverage-main should be registered")?;
let approve_pending_worktree = tempfile::tempdir()?;
let approve_context = verified_worktree(
approve_pending_worktree.path().to_path_buf(),
"https://example.test/demo",
);
let decision =
worktrees::evaluate_worktree(®istered_for_worktree, "coverage-main", &approve_context)?;
assert!(matches!(
decision,
worktrees::WorktreeDecision::ApprovalRequired { .. }
));
let request = worktrees::list_project("coverage-main")?
.pending
.last()
.cloned()
.context("coverage approve pending worktree missing")?;
worktrees_command(WorktreesCommand::Approve {
request_id: request.id,
json: false,
})
.expect("coverage pending worktree approval should succeed");
let deny_pending_worktree = tempfile::tempdir()?;
let deny_context = verified_worktree(
deny_pending_worktree.path().to_path_buf(),
"https://example.test/demo",
);
let decision =
worktrees::evaluate_worktree(®istered_for_worktree, "coverage-main", &deny_context)?;
assert!(matches!(
decision,
worktrees::WorktreeDecision::ApprovalRequired { .. }
));
let request = worktrees::list_project("coverage-main")?
.pending
.last()
.cloned()
.context("coverage deny pending worktree missing")?;
worktrees_command(WorktreesCommand::Deny {
request_id: request.id,
json: false,
})
.expect("coverage pending worktree denial should succeed");
let mut registry = registry::load_registry()?;
registry
.projects
.get_mut("coverage-main")
.expect("coverage-main should be registered")
.git_remote = Some("https://example.test/expected.git".to_string());
registry::save_registry(®istry)?;
let denied_worktree = tempfile::tempdir()?;
let denied_context = verified_worktree(
denied_worktree.path().to_path_buf(),
"https://example.test/other",
);
let denied_allowed = enforce_worktree_for_no_prompt(&resolved_main, &denied_context)?;
assert!(!denied_allowed);
registry
.projects
.get_mut("coverage-main")
.expect("coverage-main should be registered")
.git_remote = None;
registry::save_registry(®istry)?;
let missing_grant = ApprovalDecision {
approved: true,
scope: ApprovalScope::Once,
approved_env: access.env.clone(),
denied_env: Vec::new(),
source: approvals::ApprovalSource::Grant,
grant_id: None,
};
consume_once_grant_if_reused(&missing_grant)?;
handle_post_run_logging_result(7, Err(anyhow::anyhow!("coverage log failure")))?;
let mut removed_files = Vec::new();
let missing_file = project.path().join("missing");
remove_project_file_if_exists(&missing_file, &mut removed_files)?;
remove_locked_env_if_needed(&missing_file, &missing_file, &mut removed_files)?;
let no_marker = project.path().join("AGENTS.no-marker.md");
fs::write(&no_marker, "Intro\n")?;
let _ = remove_agent_instruction_section(&no_marker)?;
let retained = project.path().join("AGENTS.retained.md");
let retained_contents = "Intro\n\n<!-- ward-agent-instructions -->\nGenerated\n";
fs::write(&retained, retained_contents)?;
let _ = remove_agent_instruction_section(&retained)?;
let no_json = run(RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("No prompt without json".to_string()),
env_names: access.env.clone(),
command: vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
json: false,
no_prompt: true,
});
assert!(no_json.is_err());
let critical_run = RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("Critical pending".to_string()),
env_names: access.env.clone(),
command: vec!["sh".to_string(), "-c".to_string(), "printenv".to_string()],
json: true,
no_prompt: true,
};
run(critical_run)?;
let grant_source = approvals::ApprovalSource::ManualAllow;
unlock_vault("1h", None)?;
let receipt_context = Some(grants::GrantReceiptContext::synthetic(false));
let scope = ApprovalScope::Always;
let vault = &resolved_main.vault;
let auto_context = context::VerifiedContext {
project: "coverage-main".to_string(),
agent: "codex".to_string(),
agent_key_id: "agent:coverage".to_string(),
worktree: project.path().to_path_buf(),
branch: "main".to_string(),
git_remote: "https://example.test/demo".to_string(),
commit: "abc123".to_string(),
git_common_dir: None,
};
let session_grant = grants::persist_manual_grant(
&access,
ApprovalScope::Session,
approvals::ApprovalSource::ManualAllow,
vault,
None,
)
.expect("coverage default manual grant should persist");
assert!(grants::persist_manual_grant(
&access,
ApprovalScope::Always,
approvals::ApprovalSource::Grant,
vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.is_err());
assert!(grants::persist_manual_grant(
&access,
ApprovalScope::Deny,
approvals::ApprovalSource::ManualAllow,
vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.is_err());
assert!(grants::persist_manual_grant(
&access,
ApprovalScope::Branch,
approvals::ApprovalSource::ManualAllow,
vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.is_err());
let context_receipt = grants::GrantReceiptContext {
request_id: uuid::Uuid::new_v4(),
critical_confirmation: false,
verified_context: Some(auto_context.clone()),
};
let _ = grants::persist_manual_grant(
&access,
ApprovalScope::Once,
approvals::ApprovalSource::ManualAllow,
vault,
Some(context_receipt),
)
.expect("coverage context manual grant should persist");
grants::persist_manual_grant(&access, scope, grant_source, vault, receipt_context.clone())?;
let _ = grants::find_matching_grant(&access)?;
let _ = grants::find_matching_grant_with_context(&access, &auto_context)?;
let _ = grants::find_matching_non_always_grant(&access)?;
let _ = grants::find_matching_non_always_grant_with_context(&access, &auto_context)?;
let _ = grants::find_matching_once_grant(&access, false)?;
let _ = grants::find_matching_once_grant_with_context(&access, false, &auto_context)?;
let unlocked_run = RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("Unlocked run".to_string()),
env_names: access.env.clone(),
command: vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
json: true,
no_prompt: true,
};
run(unlocked_run)?;
let suspicious_session = ApprovalDecision {
approved: true,
scope: ApprovalScope::Session,
approved_env: access.env.clone(),
denied_env: Vec::new(),
source: approvals::ApprovalSource::LocalTty,
grant_id: None,
};
let _ = grants::persist_grant(&access, &suspicious_session, vault, receipt_context.clone())?;
let _ = grants::persist_grant(&access, &suspicious_session, vault, None)?;
let grants_edge_path = home.path().join("sessions").join("coverage-grants.jsonl");
let grants_edge_parent = grants_edge_path
.parent()
.context("coverage grants edge path has no parent")?;
fs::create_dir_all(grants_edge_parent)?;
fs::write(&grants_edge_path, "\n")?;
assert!(grants::load_grants_from_path(&grants_edge_path)?.is_empty());
assert!(!grants::consume_once_grant(uuid::Uuid::new_v4())?);
assert_eq!(grants::revoke_session_grants_at_path(&grants_edge_path)?, 0);
let mut expired_grant = session_grant.clone();
expired_grant.id = uuid::Uuid::new_v4();
expired_grant.expires_at = Some(chrono::Utc::now() - chrono::Duration::minutes(1));
let mut future_grant = session_grant.clone();
future_grant.id = uuid::Uuid::new_v4();
future_grant.expires_at = Some(chrono::Utc::now() + chrono::Duration::minutes(1));
let mut durable_grant = session_grant.clone();
durable_grant.id = uuid::Uuid::new_v4();
durable_grant.expires_at = None;
grants::append_grant_to_path(&grants_edge_path, &expired_grant)?;
grants::append_grant_to_path(&grants_edge_path, &future_grant)?;
grants::append_grant_to_path(&grants_edge_path, &durable_grant)?;
assert_eq!(
grants::prune_expired_grants_at_path(&grants_edge_path, chrono::Utc::now())?,
1
);
assert_eq!(
grants::grant_integrity_status(&expired_grant, chrono::Utc::now()),
grants::GrantIntegrityStatus::Expired
);
let mut legacy_grant = future_grant.clone();
legacy_grant.receipt = None;
assert_eq!(
grants::grant_integrity_status(&legacy_grant, chrono::Utc::now()),
grants::GrantIntegrityStatus::LegacyUnsigned
);
let mut invalid_grant = future_grant.clone();
invalid_grant.project = "tampered".to_string();
assert_eq!(
grants::grant_integrity_status(&invalid_grant, chrono::Utc::now()),
grants::GrantIntegrityStatus::Invalid
);
let _ = non_interactive_decision(&access, &suspicious)?;
let _ = non_interactive_decision_with_context(&access, &clean, None)?;
let _ = non_interactive_decision_with_context(&access, &denied, Some(&auto_context))?;
let fresh_access = AccessRequest {
project: "coverage-main".to_string(),
agent: Some("codex".to_string()),
branch: None,
action: Some("Fresh auto".to_string()),
command: "sh -c echo fresh".to_string(),
env: vec!["DATABASE_URL".to_string()],
};
let _ = non_interactive_decision_with_context(&fresh_access, &clean, Some(&auto_context))?;
let _ = non_interactive_decision_with_context(&access, &clean, Some(&auto_context))?;
broker_command(BrokerCommand::Stop)?;
let _ = unlock::clear_all_unlocks()?;
assert!(grants::persist_manual_grant(
&access,
ApprovalScope::Session,
approvals::ApprovalSource::ManualAllow,
vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.is_err());
let unavailable_session = unlock::create_run_unlock(
"coverage-main",
vault,
"coverage passphrase",
chrono::Duration::hours(1),
)
.expect("coverage unavailable signing session should be created");
crate::key_store::delete_secret(&unavailable_session.key_name)?;
assert!(grants::persist_manual_grant(
&access,
ApprovalScope::Session,
approvals::ApprovalSource::ManualAllow,
vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.is_err());
unlock_vault("1h", None)?;
let verify_logs = Some(LogsCommand::Verify {
kind: Some(LogKind::Requests),
full: true,
});
logs(verify_logs, None)?;
let export_logs = Some(LogsCommand::Export {
kind: LogKind::Requests,
output: project.path().join("requests.export.jsonl"),
force: false,
});
logs(export_logs, None)?;
assert!(teardown(None, ".env.unused".into(), false, false).is_err());
let remove_project = tempfile::tempdir()?;
env::set_current_dir(remove_project.path())?;
fs::write(remove_project.path().join(".gitignore"), ".env\n.env.*\n")?;
let remove_env = remove_project.path().join(".env");
fs::write(&remove_env, "DATABASE_URL=postgres://remove\n")?;
let remove_setup = SetupOptions {
yes: true,
project: Some("coverage-remove".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: true,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
};
setup(remove_setup)?;
let import_project = tempfile::tempdir()?;
env::set_current_dir(import_project.path())?;
init(Some("coverage-import".to_string()), false, false)?;
let import_env = import_project.path().join(".env");
fs::write(&import_env, "DATABASE_URL=postgres://import\n")?;
import(".env".into(), Some("relative.vault".into()))?;
let doctor_project = tempfile::tempdir()?;
env::set_current_dir(doctor_project.path())?;
fs::write(doctor_project.path().join(".gitignore"), ".env\n.env.*\n")?;
let doctor_env = doctor_project.path().join(".env");
let doctor_vault = doctor_project.path().join(".env.vault");
fs::write(&doctor_env, "DATABASE_URL=postgres://doctor\n")?;
let doctor_setup = SetupOptions {
yes: true,
project: Some("coverage-doctor".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
};
setup(doctor_setup)?;
doctor()?;
fs::write(&doctor_env, "DATABASE_URL=postgres://plaintext\n")?;
doctor()?;
env_file::lock_env_file(&doctor_env, &doctor_vault)?;
fs::write(&doctor_vault, "changed")?;
doctor()?;
fs::remove_file(&doctor_env)?;
doctor()?;
fs::create_dir(&doctor_env)?;
doctor()?;
let grants_path = grants::grants_path();
let grants_parent = grants_path.parent().context("grants path has no parent")?;
fs::create_dir_all(grants_parent)?;
fs::write(grants_path, "{bad-json}\n")?;
doctor()?;
crate::broker::coverage_exercise_broker_edges()?;
env::set_current_dir(old_cwd)?;
env::remove_var("WARD_HOME");
env::remove_var("WARD_UNSAFE_TEST_KEYRING");
env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
Ok(())
}
fn likely_secret_env_files(cwd: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in fs::read_dir(cwd)? {
let entry = entry?;
let path = entry.path();
let name = path
.file_name()
.map(|value| value.to_string_lossy())
.unwrap_or_default();
if name.starts_with(".env.") && name != ".env.example" && name != config::DEFAULT_VAULT_FILE
{
files.push(path);
}
}
files.sort();
Ok(files)
}
fn check_gitignore(cwd: &Path) -> Result<()> {
let path = cwd.join(".gitignore");
if !path.exists() {
term::warn(".gitignore missing — add .env and .env.*");
return Ok(());
}
let contents =
fs::read_to_string(&path).context(format!("failed to read {}", path.display()))?;
let has_env = gitignore_contains(&contents, ".env");
let has_env_variants = gitignore_contains(&contents, ".env.*");
if has_env {
term::ok(".gitignore .env");
} else {
term::warn(".gitignore should include .env");
}
if has_env_variants {
term::ok(".gitignore .env.*");
if gitignore_contains(&contents, "!.env.vault") {
term::ok(".gitignore !.env.vault");
} else {
term::info("tip: add !.env.vault after .env.* to commit encrypted vaults");
}
} else {
term::warn(".gitignore should include .env.*");
}
Ok(())
}
fn gitignore_contains(contents: &str, expected: &str) -> bool {
contents.lines().any(|line| {
let trimmed = line.trim();
!trimmed.starts_with('#') && trimmed == expected
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::ProjectConfig,
policy::{AccessRequest, ApprovalMode, PolicyEvaluation},
};
use clap::CommandFactory;
use std::{
path::{Path, PathBuf},
process::Command as StdCommand,
sync::{Mutex, OnceLock},
};
fn cwd_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn remaining_session_ttl_only_returns_positive_duration() {
let now = chrono::Utc::now();
assert_eq!(
remaining_session_ttl(now + chrono::Duration::seconds(30), now)
.unwrap()
.num_seconds(),
30
);
assert!(remaining_session_ttl(now, now).is_none());
assert!(remaining_session_ttl(now - chrono::Duration::seconds(1), now).is_none());
}
fn prepare_git_context(path: &Path, agent: &str, branch: Option<&str>) -> AgentContextOptions {
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "tester@example.test"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Tester"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["remote", "add", "origin", "https://example.test/demo.git"])
.current_dir(path)
.output()
.unwrap();
if let Some(branch_name) = branch {
StdCommand::new("git")
.args(["checkout", "-B", branch_name])
.current_dir(path)
.output()
.unwrap();
}
StdCommand::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.env("GIT_AUTHOR_NAME", "Tester")
.env("GIT_AUTHOR_EMAIL", "tester@example.test")
.env("GIT_COMMITTER_NAME", "Tester")
.env("GIT_COMMITTER_EMAIL", "tester@example.test")
.current_dir(path)
.output()
.unwrap();
let branch_name = branch.map(str::to_string).unwrap_or_else(|| {
String::from_utf8(
StdCommand::new("git")
.args(["branch", "--show-current"])
.current_dir(path)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string()
});
let commit = String::from_utf8(
StdCommand::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(path)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
AgentContextOptions {
agent: Some(agent.to_string()),
agent_key_id: None,
worktree: Some(path.to_path_buf()),
git_remote: Some("https://example.test/demo.git".to_string()),
commit: Some(commit),
branch: Some(branch_name),
}
}
#[test]
#[serial_test::serial]
fn broker_and_worktree_command_helpers_execute_all_branches() {
let _guard = cwd_lock();
let home = tempfile::tempdir().unwrap();
std::env::set_var("WARD_HOME", home.path());
broker_command(BrokerCommand::SocketPath).unwrap();
broker_command(BrokerCommand::Status).unwrap();
broker_command(BrokerCommand::Stop).unwrap();
let root = tempfile::tempdir().unwrap();
worktrees_command(WorktreesCommand::AllowRoot {
project: "demo".to_string(),
path: root.path().to_path_buf(),
})
.unwrap();
worktrees_command(WorktreesCommand::List {
project: "demo".to_string(),
})
.unwrap();
worktrees_command(WorktreesCommand::RemoveRoot {
project: "demo".to_string(),
path: root.path().to_path_buf(),
})
.unwrap();
worktrees_command(WorktreesCommand::RemoveRoot {
project: "demo".to_string(),
path: root.path().to_path_buf(),
})
.unwrap();
worktrees_command(WorktreesCommand::Approve {
request_id: uuid::Uuid::new_v4(),
json: false,
})
.unwrap();
worktrees_command(WorktreesCommand::Deny {
request_id: uuid::Uuid::new_v4(),
json: false,
})
.unwrap();
std::env::remove_var("WARD_HOME");
}
#[test]
fn verified_agent_key_id_extracts_optional_context() {
assert_eq!(verified_agent_key_id(None), None);
let context = context::VerifiedContext {
project: "demo".to_string(),
agent: "codex".to_string(),
agent_key_id: "agent:demo".to_string(),
worktree: PathBuf::from("/tmp/demo"),
branch: "main".to_string(),
git_remote: "https://example.test/demo.git".to_string(),
commit: "abc123".to_string(),
git_common_dir: None,
};
assert_eq!(verified_agent_key_id(Some(&context)), Some("agent:demo"));
}
#[test]
#[serial_test::serial]
fn non_interactive_context_can_auto_approve_without_prompt_or_grant() {
let _guard = cwd_lock();
let home = tempfile::tempdir().unwrap();
std::env::set_var("WARD_HOME", home.path());
let context = context::VerifiedContext {
project: "demo".to_string(),
agent: "codex".to_string(),
agent_key_id: "agent:demo".to_string(),
worktree: PathBuf::from("/tmp/demo"),
branch: "main".to_string(),
git_remote: "https://example.test/demo.git".to_string(),
commit: "abc123".to_string(),
git_common_dir: None,
};
let decision = non_interactive_decision_with_context(
&access(),
&evaluation(ApprovalMode::Auto, false),
Some(&context),
)
.unwrap()
.unwrap();
assert!(decision.approved);
assert_eq!(decision.source, approvals::ApprovalSource::PolicyAuto);
std::env::remove_var("WARD_HOME");
}
fn access() -> AccessRequest {
AccessRequest {
project: "demo".to_string(),
agent: None,
branch: None,
action: None,
command: "pnpm dev".to_string(),
env: vec!["DATABASE_URL".to_string()],
}
}
fn evaluation(mode: ApprovalMode, requires_prompt: bool) -> PolicyEvaluation {
PolicyEvaluation {
matched_profile: None,
matched_preset: None,
matched_mode: None,
approval_mode: mode,
requested_env: vec!["DATABASE_URL".to_string()],
approved_env: vec!["DATABASE_URL".to_string()],
denied_env: Vec::new(),
requires_prompt,
findings: Vec::new(),
}
}
fn setup_test_signing_unlock(home: &std::path::Path, project: &str) -> PathBuf {
std::env::set_var("WARD_HOME", home);
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
let vault = home.join(format!("{project}.env.vault"));
unlock::create_run_unlock(
project,
&vault,
"coverage passphrase",
chrono::Duration::hours(1),
)
.unwrap();
vault
}
fn clear_test_signing_unlock() {
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
}
#[test]
fn prepare_git_context_reads_current_branch_when_not_supplied() {
let _guard = cwd_lock();
let project = tempfile::tempdir().unwrap();
let context = prepare_git_context(project.path(), "codex", None);
assert!(!context.branch.as_deref().unwrap_or_default().is_empty());
}
#[test]
#[serial_test::serial]
fn no_prompt_run_and_non_interactive_helpers_cover_json_edges() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let keep_project = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_current_dir(keep_project.path()).unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
std::fs::write(keep_project.path().join(".gitignore"), ".env\n.env.*\n").unwrap();
std::fs::write(
keep_project.path().join(".env"),
"DATABASE_URL=postgres://kept\n",
)
.unwrap();
dispatch(Cli {
command: Commands::Setup {
yes: true,
project: Some("kept".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: true,
unlock_ttl: "8h".to_string(),
no_unlock: false,
},
})
.unwrap();
assert!(keep_project.path().join(".env").exists());
std::env::set_current_dir(project.path()).unwrap();
std::fs::write(project.path().join(".gitignore"), ".env\n.env.*\n").unwrap();
std::fs::write(
project.path().join(".env"),
"DATABASE_URL=postgres://coverage\n",
)
.unwrap();
setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap();
let no_json_error = run(RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("No JSON".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
command: vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
json: false,
no_prompt: true,
})
.unwrap_err()
.to_string();
assert!(no_json_error.contains("--no-prompt requires --json"));
run(RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("Needs approval".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
command: vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
json: true,
no_prompt: true,
})
.unwrap();
let mut project_config = config::read_project_config(project.path()).unwrap();
project_config.presets.push(config::PresetConfig {
name: "Deny shell".to_string(),
match_commands: vec!["sh -c false".to_string()],
allowed_env: Vec::new(),
approval: ApprovalMode::Deny,
});
config::write_project_config(project.path(), &project_config, true).unwrap();
run(RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("Denied no prompt".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
command: vec!["sh".to_string(), "-c".to_string(), "false".to_string()],
json: true,
no_prompt: true,
})
.unwrap();
let access = access();
let mut denied = evaluation(ApprovalMode::Deny, false);
denied.denied_env = vec!["DATABASE_URL".to_string()];
let denied_decision = non_interactive_decision(&access, &denied).unwrap().unwrap();
assert!(!denied_decision.approved);
print_run_denied(&access, &denied).unwrap();
assert_eq!(run_risk_summary(&denied), "warning");
let mut critical = evaluation(ApprovalMode::Prompt, true);
critical.findings.push(crate::detection::Finding::critical(
"critical.test",
"critical finding",
));
assert_eq!(run_risk_summary(&critical), "critical");
let clean = evaluation(ApprovalMode::Auto, false);
assert_eq!(run_risk_summary(&clean), "low");
assert!(
non_interactive_decision(&access, &clean)
.unwrap()
.unwrap()
.approved
);
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
#[serial_test::serial]
fn doctor_reports_stale_locked_env_and_env_state_errors() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_current_dir(project.path()).unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
std::fs::write(project.path().join(".gitignore"), ".env\n.env.*\n").unwrap();
std::fs::write(
project.path().join(".env"),
"DATABASE_URL=postgres://coverage\n",
)
.unwrap();
setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap();
std::fs::write(project.path().join(".env.vault"), "changed").unwrap();
doctor().unwrap();
std::fs::remove_file(project.path().join(".env")).unwrap();
std::fs::create_dir(project.path().join(".env")).unwrap();
doctor().unwrap();
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
fn remove_agent_instruction_section_handles_marker_edges() {
let tempdir = tempfile::tempdir().unwrap();
let missing = tempdir.path().join("missing.md");
assert!(!remove_agent_instruction_section(&missing).unwrap());
let mut removed_files = Vec::new();
remove_project_file_if_exists(&missing, &mut removed_files).unwrap();
assert!(removed_files.is_empty());
remove_locked_env_if_needed(&missing, &missing, &mut removed_files).unwrap();
assert!(removed_files.is_empty());
let remove_me = tempdir.path().join("remove-me");
std::fs::write(&remove_me, "temporary").unwrap();
remove_project_file_if_exists(&remove_me, &mut removed_files).unwrap();
assert!(!remove_me.exists());
assert_eq!(removed_files.len(), 1);
let vault = tempdir.path().join(".env.vault");
std::fs::write(&vault, "encrypted").unwrap();
let locked_env = tempdir.path().join(".env");
env_file::lock_env_file(&locked_env, &vault).unwrap();
remove_locked_env_if_needed(
&locked_env,
&tempdir.path().join(".env.export"),
&mut removed_files,
)
.unwrap();
assert!(!locked_env.exists());
let no_marker = tempdir.path().join("no-marker.md");
std::fs::write(&no_marker, "Intro\n").unwrap();
assert!(!remove_agent_instruction_section(&no_marker).unwrap());
let retained = tempdir.path().join("retained.md");
std::fs::write(
&retained,
"Intro\n\n<!-- ward-agent-instructions -->\nGenerated\n",
)
.unwrap();
assert!(remove_agent_instruction_section(&retained).unwrap());
assert_eq!(std::fs::read_to_string(&retained).unwrap(), "Intro\n");
}
#[test]
#[serial_test::serial]
fn setup_reports_missing_source_and_registry_failures() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
std::env::set_current_dir(project.path()).unwrap();
let dispatch_conflict = dispatch(Cli {
command: Commands::Setup {
yes: true,
project: Some("demo".to_string()),
source: "missing.env".into(),
vault: "missing.vault".into(),
commit_vault: true,
ignore_vault: true,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: true,
},
})
.unwrap_err()
.to_string();
assert!(dispatch_conflict.contains("choose either --commit-vault or --ignore-vault"));
let plaintext_conflict = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: "missing.env".into(),
vault: "missing.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: true,
keep_plaintext: true,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap_err()
.to_string();
assert!(plaintext_conflict.contains("choose either --remove-plaintext or --keep-plaintext"));
let missing = setup(SetupOptions {
yes: false,
project: Some("demo".to_string()),
source: "missing.env".into(),
vault: "missing.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap_err()
.to_string();
assert!(missing.contains("missing.env does not exist"));
let absolute_vault = project.path().join("absolute.env.vault");
std::fs::write(&absolute_vault, "placeholder").unwrap();
let bad_home = project.path().join("not-a-dir");
std::fs::write(&bad_home, "file").unwrap();
std::env::set_var("WARD_HOME", &bad_home);
let registry_error = setup(SetupOptions {
yes: false,
project: Some("demo".to_string()),
source: "missing.env".into(),
vault: absolute_vault,
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap_err()
.to_string();
assert!(registry_error.contains("failed to create"));
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_HOME");
}
#[test]
#[serial_test::serial]
fn setup_updates_existing_config_without_yes_output() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_current_dir(project.path()).unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
let config =
ProjectConfig::default_for_dir(project.path(), Some("old-demo".to_string())).unwrap();
config::write_project_config(project.path(), &config, false).unwrap();
let seed_env = project.path().join("seed.env");
std::fs::write(&seed_env, "DATABASE_URL=postgres://coverage\n").unwrap();
vault::import_env_file(
&seed_env,
&project.path().join(".env.vault"),
"coverage passphrase",
)
.unwrap();
setup(SetupOptions {
yes: false,
project: Some("new-demo".to_string()),
source: "missing.env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap();
assert_eq!(
config::read_project_config(project.path()).unwrap().project,
"new-demo"
);
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
#[serial_test::serial]
fn setup_imports_source_env_in_unit_flow() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_current_dir(project.path()).unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
std::fs::write(
project.path().join(".env"),
"DATABASE_URL=postgres://coverage\nPAYLOAD_SECRET=payload\n",
)
.unwrap();
setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap();
assert!(env_file::is_locked_env_file(&project.path().join(".env")).unwrap());
assert!(project.path().join(".env.vault").exists());
setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap();
assert!(vault::decrypt_vault_file(
&project.path().join(".env.vault"),
"coverage passphrase"
)
.unwrap()
.contains("postgres://coverage"));
std::fs::remove_file(project.path().join(".env.vault")).unwrap();
let missing_vault = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap_err()
.to_string();
assert!(missing_vault.contains("Ward locked marker"));
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
#[serial_test::serial]
fn setup_can_still_remove_plaintext_when_deprecated_flag_is_explicit() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_current_dir(project.path()).unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
std::fs::write(project.path().join(".gitignore"), ".env\n.env.*\n").unwrap();
std::fs::write(
project.path().join(".env"),
"DATABASE_URL=postgres://coverage\n",
)
.unwrap();
setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: true,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.unwrap();
assert!(!project.path().join(".env").exists());
assert!(project.path().join(".env.vault").exists());
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
#[serial_test::serial]
fn dispatch_covers_projects_env_unlock_required_and_teardown_paths() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_current_dir(project.path()).unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
std::fs::write(project.path().join(".gitignore"), ".env\n.env.*\n").unwrap();
std::fs::write(
project.path().join(".env"),
"DATABASE_URL=postgres://coverage\nPAYLOAD_SECRET=payload\n",
)
.unwrap();
dispatch(Cli {
command: Commands::Setup {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: true,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Projects {
command: ProjectsCommand::List,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Projects {
command: ProjectsCommand::Show {
project: Some("demo".to_string()),
},
},
})
.unwrap();
dispatch(Cli {
command: Commands::Projects {
command: ProjectsCommand::Use {
project: "demo".to_string(),
},
},
})
.unwrap();
dispatch(Cli {
command: Commands::Projects {
command: ProjectsCommand::Register {
project: "temporary".to_string(),
path: Some(project.path().to_path_buf()),
vault: Some(".env.vault".into()),
},
},
})
.unwrap();
dispatch(Cli {
command: Commands::Projects {
command: ProjectsCommand::Remove {
project: "temporary".to_string(),
},
},
})
.unwrap();
dispatch(Cli {
command: Commands::Projects {
command: ProjectsCommand::Remove {
project: "missing".to_string(),
},
},
})
.unwrap();
for command in [
EnvCommand::List { project: None },
EnvCommand::Set {
project: None,
assignment: "OPENAI_API_KEY=sk-test".to_string(),
},
EnvCommand::Unset {
project: None,
key: "OPENAI_API_KEY".to_string(),
},
EnvCommand::Unset {
project: None,
key: "MISSING_ENV".to_string(),
},
EnvCommand::Unlock {
project: None,
output: ".env.manual".into(),
force: false,
},
EnvCommand::Lock {
project: None,
source: ".env.manual".into(),
},
EnvCommand::Export {
project: None,
output: None,
force: true,
unsafe_stdout: false,
},
EnvCommand::Export {
project: None,
output: Some(".env.dispatch.export".into()),
force: false,
unsafe_stdout: false,
},
] {
dispatch(Cli {
command: Commands::Env { command },
})
.unwrap();
}
let mut project_config = config::read_project_config(project.path()).unwrap();
project_config
.profiles
.get_mut("dev")
.expect("setup creates the dev profile")
.command = "sh -c true".to_string();
config::write_project_config(project.path(), &project_config, true).unwrap();
unlock_vault("1h", None).unwrap();
let agent_context = prepare_git_context(project.path(), "codex", Some("feature/dispatch"));
dispatch(Cli {
command: Commands::Allow {
profile: Some("dev".to_string()),
scope: Some(ApprovalScope::Always),
agent: Some("codex".to_string()),
branch: None,
command: None,
env_names: Vec::new(),
},
})
.unwrap();
dispatch(Cli {
command: Commands::Run {
profile: Some("dev".to_string()),
project: None,
agent: Some("codex".to_string()),
agent_key_id: agent_context.agent_key_id.clone(),
worktree: agent_context.worktree.clone(),
git_remote: agent_context.git_remote.clone(),
commit: agent_context.commit.clone(),
branch: agent_context.branch.clone(),
action: None,
env_names: Vec::new(),
json: true,
no_prompt: true,
command: Vec::new(),
},
})
.unwrap();
dispatch(Cli {
command: Commands::Request {
profile: None,
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: None,
action: Some("Leave pending".to_string()),
command: Some("pnpm test".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
json: true,
no_prompt: true,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Teardown {
project: None,
export_path: ".env.final".into(),
yes: true,
restore_env: false,
},
})
.unwrap();
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
#[serial_test::serial]
fn setup_reports_source_config_import_and_log_failures() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
let invalid_project = tempfile::tempdir().unwrap();
std::env::set_current_dir(invalid_project.path()).unwrap();
std::fs::write(
invalid_project.path().join(".env"),
"DATABASE_URL='unterminated\n",
)
.unwrap();
let invalid_source = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: true,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.expect_err("invalid dotenv should fail setup")
.to_string();
let config_blocked = tempfile::tempdir().unwrap();
std::env::set_current_dir(config_blocked.path()).unwrap();
std::fs::write(config_blocked.path().join(".env.vault"), "placeholder").unwrap();
std::fs::create_dir(config_blocked.path().join(".ward.json")).unwrap();
let config_error = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: "missing.env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.expect_err("blocked config path should fail setup")
.to_string();
let import_blocked = tempfile::tempdir().unwrap();
std::env::set_current_dir(import_blocked.path()).unwrap();
std::fs::write(
import_blocked.path().join(".env"),
"DATABASE_URL=postgres://coverage\n",
)
.unwrap();
std::fs::create_dir(import_blocked.path().join(".env.vault")).unwrap();
let import_error = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: true,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.expect_err("directory vault path should fail setup")
.to_string();
let env_example_blocked = tempfile::tempdir().unwrap();
std::env::set_current_dir(env_example_blocked.path()).unwrap();
std::fs::write(env_example_blocked.path().join(".env.vault"), "placeholder").unwrap();
std::fs::create_dir(env_example_blocked.path().join(".env.example")).unwrap();
let env_example_error = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: "missing.env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.expect_err("directory .env.example should fail setup")
.to_string();
let instructions_blocked = tempfile::tempdir().unwrap();
std::env::set_current_dir(instructions_blocked.path()).unwrap();
std::fs::write(
instructions_blocked.path().join(".env.vault"),
"placeholder",
)
.unwrap();
std::fs::create_dir(instructions_blocked.path().join("AGENTS.md")).unwrap();
let instructions_error = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: "missing.env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.expect_err("directory AGENTS.md should fail setup")
.to_string();
let gitignore_blocked = tempfile::tempdir().unwrap();
std::env::set_current_dir(gitignore_blocked.path()).unwrap();
std::fs::write(gitignore_blocked.path().join(".env.vault"), "placeholder").unwrap();
std::fs::create_dir(gitignore_blocked.path().join(".gitignore")).unwrap();
let gitignore_error = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: "missing.env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.expect_err("directory .gitignore should fail setup")
.to_string();
#[cfg(unix)]
let log_error = {
let log_blocked = tempfile::tempdir().unwrap();
std::env::set_current_dir(log_blocked.path()).unwrap();
std::fs::write(
log_blocked.path().join(".env"),
"DATABASE_URL=postgres://coverage\n",
)
.unwrap();
std::fs::create_dir_all(home.path().join("logs/sessions.jsonl")).unwrap();
let error = setup(SetupOptions {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: false,
ignore_vault: false,
remove_plaintext: false,
keep_plaintext: true,
unlock_ttl: "8h".to_string(),
no_unlock: false,
})
.expect_err("directory log file should fail setup")
.to_string();
std::fs::remove_dir_all(home.path().join("logs/sessions.jsonl")).unwrap();
error
};
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
assert!(invalid_source.contains("failed to parse"));
assert!(config_error.contains("failed to write"));
assert!(import_error.contains("failed to write"));
assert!(env_example_error.contains("failed to read"));
assert!(instructions_error.contains("failed to read"));
assert!(gitignore_error.contains("failed to read"));
#[cfg(unix)]
assert!(log_error.contains("failed to read"));
}
#[test]
#[serial_test::serial]
fn dispatch_and_stateful_commands_cover_cli_paths() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
std::env::set_current_dir(project.path()).unwrap();
std::fs::write(
project.path().join(".env"),
"DATABASE_URL=postgres://coverage\nPAYLOAD_SECRET=payload\n",
)
.unwrap();
std::fs::write(project.path().join(".gitignore"), ".env\n.env.*\n").unwrap();
dispatch(Cli {
command: Commands::Init {
project: Some("demo".to_string()),
force: false,
bare: true,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Import {
source: ".env".into(),
vault: None,
},
})
.unwrap();
std::fs::write(
project.path().join(".env.alt"),
"DATABASE_URL=postgres://coverage-alt\nPAYLOAD_SECRET=payload-alt\n",
)
.unwrap();
dispatch(Cli {
command: Commands::Import {
source: ".env.alt".into(),
vault: Some(".env.alt.vault".into()),
},
})
.unwrap();
let mut config_after_alt_import = config::read_project_config(project.path()).unwrap();
config_after_alt_import.vault = ".env.vault".into();
config::write_project_config(project.path(), &config_after_alt_import, true).unwrap();
std::fs::remove_file(project.path().join(".env")).unwrap();
dispatch(Cli {
command: Commands::Register {
project: "demo".to_string(),
path: None,
vault: None,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Register {
project: "demo-alt".to_string(),
path: None,
vault: Some(project.path().join(".env.alt.vault")),
},
})
.unwrap();
dispatch(Cli {
command: Commands::Use {
project: "demo".to_string(),
},
})
.unwrap();
init(Some("demo".to_string()), true, true).unwrap();
let mut project_config = config::read_project_config(project.path()).unwrap();
let dev_profile = project_config.profiles.get_mut("dev").unwrap();
dev_profile.command = "sh -c true".to_string();
dev_profile.env = vec!["DATABASE_URL".to_string(), "PAYLOAD_SECRET".to_string()];
let migrate_profile = project_config.profiles.get_mut("migrate").unwrap();
migrate_profile.command = "sh -c true".to_string();
migrate_profile.env = vec!["DATABASE_URL".to_string(), "PAYLOAD_SECRET".to_string()];
migrate_profile.default_scope = ApprovalScope::Always;
config::write_project_config(project.path(), &project_config, true).unwrap();
unlock_vault("1h", None).unwrap();
let agent_context = prepare_git_context(project.path(), "codex", Some("feature/dispatch"));
assert!(request(
None,
AgentContextOptions {
agent: Some("codex".to_string()),
branch: None,
..AgentContextOptions::default()
},
Some("No prompt without json".to_string()),
Some("sh -c true".to_string()),
vec!["DATABASE_URL".to_string()],
false,
true,
)
.is_err());
dispatch(Cli {
command: Commands::Request {
profile: None,
agent: Some("codex".to_string()),
agent_key_id: agent_context.agent_key_id.clone(),
worktree: agent_context.worktree.clone(),
git_remote: agent_context.git_remote.clone(),
commit: agent_context.commit.clone(),
branch: agent_context.branch.clone(),
action: Some("Run request".to_string()),
command: Some("sh -c true".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
json: true,
no_prompt: true,
},
})
.unwrap();
let pending_id = pending_requests::requests_dir()
.read_dir()
.unwrap()
.next()
.unwrap()
.unwrap()
.path()
.file_stem()
.unwrap()
.to_string_lossy()
.parse::<uuid::Uuid>()
.unwrap();
dispatch(Cli {
command: Commands::Approve {
request_id: pending_id,
scope: ApprovalScope::Once,
confirm_critical: false,
agent_mediated: true,
json: false,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Allow {
profile: None,
scope: Some(ApprovalScope::Always),
agent: Some("codex".to_string()),
branch: None,
command: Some("sh -c true".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
},
})
.unwrap();
assert!(allow(
None,
Some(ApprovalScope::Deny),
None,
None,
Some("sh -c true".to_string()),
vec!["DATABASE_URL".to_string()],
)
.is_err());
assert!(allow(
None,
None,
None,
None,
Some("sh -c true".to_string()),
vec!["DATABASE_URL".to_string()],
)
.is_err());
assert!(allow(
Some("dev".to_string()),
Some(ApprovalScope::Always),
None,
None,
Some("sh -c true".to_string()),
Vec::new(),
)
.is_err());
std::env::set_var("WARD_UNSAFE_TEST_APPROVAL", "deny");
assert!(run(RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("Denied run".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
command: vec!["sh".to_string(), "-c".to_string(), "false".to_string()],
json: false,
no_prompt: false,
})
.is_err());
std::env::remove_var("WARD_UNSAFE_TEST_APPROVAL");
allow(
Some("dev".to_string()),
None,
Some("codex".to_string()),
None,
None,
Vec::new(),
)
.unwrap();
allow(
Some("migrate".to_string()),
None,
Some("codex".to_string()),
None,
None,
Vec::new(),
)
.unwrap();
dispatch(Cli {
command: Commands::Run {
profile: None,
project: None,
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: None,
action: Some("Run without cached unlock".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
json: false,
no_prompt: false,
command: vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
},
})
.unwrap();
dispatch(Cli {
command: Commands::Unlock {
ttl: "1h".to_string(),
mode: None,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Run {
profile: None,
project: None,
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: None,
action: Some("Run allowed command".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
json: false,
no_prompt: false,
command: vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
},
})
.unwrap();
std::env::set_var("WARD_UNSAFE_TEST_APPROVAL", "once");
run(RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("Echo secret for redaction".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
command: vec![
"sh".to_string(),
"-c".to_string(),
"printf '%s\\n' \"$DATABASE_URL\"".to_string(),
],
json: false,
no_prompt: false,
})
.unwrap();
std::env::remove_var("WARD_UNSAFE_TEST_APPROVAL");
std::env::set_var("WARD_UNSAFE_TEST_APPROVAL", "once");
let child_error = run(RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("Child failure".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
command: vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()],
json: false,
no_prompt: false,
})
.unwrap_err();
assert_eq!(
child_error.downcast_ref::<ChildExit>().unwrap().exit_code(),
7
);
std::env::remove_var("WARD_UNSAFE_TEST_APPROVAL");
dispatch(Cli {
command: Commands::Dev {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: None,
json: false,
no_prompt: false,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Migrate {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: None,
json: false,
no_prompt: false,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Run {
profile: None,
project: None,
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: Some("feature/dispatch".to_string()),
action: Some("Run once command".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
json: false,
no_prompt: false,
command: vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
},
})
.unwrap();
dispatch(Cli {
command: Commands::Grants {
command: GrantsCommand::List,
},
})
.unwrap();
let grant_id = grants::load_grants().unwrap()[0].id;
dispatch(Cli {
command: Commands::Grants {
command: GrantsCommand::Revoke { grant_id },
},
})
.unwrap();
dispatch(Cli {
command: Commands::Grants {
command: GrantsCommand::Revoke {
grant_id: uuid::Uuid::new_v4(),
},
},
})
.unwrap();
dispatch(Cli {
command: Commands::Grants {
command: GrantsCommand::Prune,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Logs {
command: None,
kind: None,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Logs {
command: None,
kind: Some(LogKind::Requests),
},
})
.unwrap();
dispatch(Cli {
command: Commands::Logs {
command: Some(LogsCommand::View {
kind: LogKind::Executions,
}),
kind: None,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Logs {
command: Some(LogsCommand::Unlock {
ttl: "15m".to_string(),
}),
kind: None,
},
})
.unwrap();
dispatch(Cli {
command: Commands::Logs {
command: Some(LogsCommand::Verify {
kind: None,
full: false,
}),
kind: None,
},
})
.unwrap();
let editor = project.path().join("edit-env.sh");
std::fs::write(
&editor,
"#!/bin/sh\ncat > \"$1\" <<'EOF'\nDATABASE_URL=postgres://edited\nPAYLOAD_SECRET=payload\nEOF\n",
)
.unwrap();
make_executable(&editor);
std::env::set_var("EDITOR", &editor);
dispatch(Cli {
command: Commands::Edit,
})
.unwrap();
dispatch(Cli {
command: Commands::Doctor,
})
.unwrap();
dispatch(Cli {
command: Commands::Lock,
})
.unwrap();
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("EDITOR");
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
#[serial_test::serial]
fn approve_deny_json_and_post_log_helpers_cover_edges() {
let _guard = cwd_lock();
let old_cwd = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
std::env::set_var("WARD_UNSAFE_TEST_APPROVAL", "session");
std::env::set_current_dir(project.path()).unwrap();
std::fs::write(
project.path().join(".env"),
"DATABASE_URL=postgres://coverage\n",
)
.unwrap();
init(Some("demo".to_string()), false, true).unwrap();
import(".env".into(), None).unwrap();
std::fs::remove_file(project.path().join(".env")).unwrap();
register("demo".to_string(), None, None).unwrap();
unlock_vault("1h", None).unwrap();
ensure_logs_passphrase().unwrap();
ensure_logs_passphrase().unwrap();
request(
None,
AgentContextOptions {
agent: Some("codex".to_string()),
branch: None,
..AgentContextOptions::default()
},
Some("Prompt text".to_string()),
Some("pnpm dev".to_string()),
vec!["DATABASE_URL".to_string()],
false,
false,
)
.unwrap();
request(
None,
AgentContextOptions {
agent: Some("codex".to_string()),
branch: None,
..AgentContextOptions::default()
},
Some("Prompt json".to_string()),
Some("sh -c true".to_string()),
vec!["DATABASE_URL".to_string()],
true,
false,
)
.unwrap();
run(RunOptions {
profile: None,
project: None,
agent: Some("codex".to_string()),
branch: None,
action: Some("Prompt run".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
command: vec![
"sh".to_string(),
"-c".to_string(),
"printf run >/dev/null".to_string(),
],
json: false,
no_prompt: false,
})
.unwrap();
std::env::set_var("WARD_UNSAFE_TEST_APPROVAL", "deny");
request(
None,
AgentContextOptions {
agent: Some("codex".to_string()),
branch: None,
..AgentContextOptions::default()
},
Some("Prompt deny".to_string()),
Some("sh -c false".to_string()),
vec!["DATABASE_URL".to_string()],
false,
false,
)
.unwrap();
std::env::set_var("WARD_UNSAFE_TEST_APPROVAL", "session");
let pending = pending_requests::create_pending_request(
AccessRequest {
project: "demo".to_string(),
agent: Some("codex".to_string()),
branch: None,
action: Some("Deny pending".to_string()),
command: "sh -c false".to_string(),
env: vec!["DATABASE_URL".to_string()],
},
evaluation(ApprovalMode::Prompt, true),
git_context::GitContext::default(),
)
.unwrap();
assert!(approve(pending.id, ApprovalScope::Deny, false, false, false).is_err());
deny(pending.id, false, false).unwrap();
let missing_grant_id = ApprovalDecision {
approved: true,
scope: ApprovalScope::Once,
approved_env: vec!["DATABASE_URL".to_string()],
denied_env: Vec::new(),
source: approvals::ApprovalSource::Grant,
grant_id: None,
};
consume_once_grant_if_reused(&missing_grant_id).unwrap();
let non_once_grant = ApprovalDecision {
scope: ApprovalScope::Always,
..missing_grant_id.clone()
};
consume_once_grant_if_reused(&non_once_grant).unwrap();
warn_anomaly_failure(Ok(()));
warn_anomaly_failure(Err(anyhow::anyhow!("anomaly fail")));
let pending = pending_requests::create_pending_request(
AccessRequest {
project: "demo".to_string(),
agent: Some("codex".to_string()),
branch: None,
action: Some("Approve pending".to_string()),
command: "sh -c true".to_string(),
env: vec!["DATABASE_URL".to_string()],
},
evaluation(ApprovalMode::Prompt, true),
git_context::GitContext::default(),
)
.unwrap();
approve(pending.id, ApprovalScope::Session, false, false, false).unwrap();
let mut critical_policy = evaluation(ApprovalMode::Prompt, true);
critical_policy
.findings
.push(detection::Finding::critical("critical.test", "critical"));
let critical_pending = pending_requests::create_pending_request(
AccessRequest {
project: "demo".to_string(),
agent: Some("codex".to_string()),
branch: None,
action: Some("Critical pending".to_string()),
command: "sh -c printenv".to_string(),
env: vec!["DATABASE_URL".to_string()],
},
critical_policy,
git_context::GitContext::default(),
)
.unwrap();
assert!(approve(critical_pending.id, ApprovalScope::Once, false, true, false).is_err());
assert!(pending_requests::load_pending_request(critical_pending.id).is_ok());
assert!(approve(
critical_pending.id,
ApprovalScope::Session,
true,
true,
false
)
.is_err());
approve(critical_pending.id, ApprovalScope::Once, true, true, false).unwrap();
let once_grant = grants::load_grants()
.unwrap()
.into_iter()
.find(|grant| grant.scope == ApprovalScope::Once)
.unwrap();
let once_reuse = ApprovalDecision {
approved: true,
scope: ApprovalScope::Once,
approved_env: vec!["DATABASE_URL".to_string()],
denied_env: Vec::new(),
source: approvals::ApprovalSource::Grant,
grant_id: Some(once_grant.id),
};
consume_once_grant_if_reused(&once_reuse).unwrap();
grants_command(GrantsCommand::List).unwrap();
let signed_grant = grants::load_grants()
.unwrap()
.into_iter()
.find(|grant| grant.receipt.is_some())
.unwrap();
let mut legacy_grant = signed_grant.clone();
legacy_grant.id = uuid::Uuid::new_v4();
legacy_grant.receipt = None;
grants::append_grant_to_path(&grants::grants_path(), &legacy_grant).unwrap();
let mut invalid_grant = signed_grant;
invalid_grant.id = uuid::Uuid::new_v4();
invalid_grant.command = "sh -c tampered".to_string();
grants::append_grant_to_path(&grants::grants_path(), &invalid_grant).unwrap();
doctor().unwrap();
let pending = pending_requests::create_pending_request(
AccessRequest {
project: "demo".to_string(),
agent: Some("codex".to_string()),
branch: None,
action: Some("Deny pending via dispatch".to_string()),
command: "sh -c true".to_string(),
env: vec!["DATABASE_URL".to_string()],
},
evaluation(ApprovalMode::Prompt, true),
git_context::GitContext::default(),
)
.unwrap();
dispatch(Cli {
command: Commands::Deny {
request_id: pending.id,
agent_mediated: true,
json: false,
},
})
.unwrap();
assert!(handle_post_run_logging_result(0, Err(anyhow::anyhow!("log fail"))).is_err());
assert!(handle_post_run_logging_result(7, Err(anyhow::anyhow!("log fail"))).is_ok());
assert!(allow(
None,
Some(ApprovalScope::Always),
None,
None,
Some("sh -c printenv".to_string()),
vec!["DATABASE_URL".to_string()],
)
.is_err());
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "wrong passphrase");
assert!(unlock_vault("1h", None).is_err());
std::env::set_current_dir(old_cwd).unwrap();
std::env::remove_var("WARD_UNSAFE_TEST_APPROVAL");
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
fn decide_access_handles_policy_deny_auto_and_no_grant_lookup() {
let access = access();
let denied = decide_access(&access, &evaluation(ApprovalMode::Deny, false), true).unwrap();
assert!(!denied.approved);
assert_eq!(denied.source, approvals::ApprovalSource::PolicyDeny);
let auto = decide_access(&access, &evaluation(ApprovalMode::Auto, false), false).unwrap();
assert!(auto.approved);
assert_eq!(auto.source, approvals::ApprovalSource::PolicyAuto);
}
#[test]
#[serial_test::serial]
fn decide_access_reuses_matching_grant_and_prompts_without_grant() {
let _guard = cwd_lock();
let tempdir = tempfile::tempdir().unwrap();
let vault = setup_test_signing_unlock(tempdir.path(), "demo");
let access = access();
let decision = ApprovalDecision {
approved: true,
scope: ApprovalScope::Always,
approved_env: vec!["DATABASE_URL".to_string()],
denied_env: Vec::new(),
source: approvals::ApprovalSource::LocalTty,
grant_id: None,
};
grants::persist_grant(
&access,
&decision,
&vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.unwrap();
let reused = decide_access(&access, &evaluation(ApprovalMode::Prompt, true), true).unwrap();
assert_eq!(reused.source, approvals::ApprovalSource::Grant);
std::env::set_var("WARD_UNSAFE_TEST_APPROVAL", "once");
let prompted =
decide_access(&access, &evaluation(ApprovalMode::Prompt, true), false).unwrap();
assert_eq!(prompted.source, approvals::ApprovalSource::LocalTty);
std::env::remove_var("WARD_UNSAFE_TEST_APPROVAL");
clear_test_signing_unlock();
}
#[test]
#[serial_test::serial]
fn decide_access_bypasses_durable_grants_for_critical_findings() {
let _guard = cwd_lock();
let tempdir = tempfile::tempdir().unwrap();
let vault = setup_test_signing_unlock(tempdir.path(), "demo");
std::env::set_var("WARD_UNSAFE_TEST_APPROVAL", "once");
let mut access = access();
access.command = "sh -c printenv".to_string();
let durable = ApprovalDecision {
approved: true,
scope: ApprovalScope::Always,
approved_env: vec!["DATABASE_URL".to_string()],
denied_env: Vec::new(),
source: approvals::ApprovalSource::LocalTty,
grant_id: None,
};
grants::persist_grant(
&access,
&durable,
&vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.unwrap();
let mut evaluation = evaluation(ApprovalMode::Prompt, true);
evaluation
.findings
.push(detection::Finding::critical("critical.test", "critical"));
let prompted = decide_access(&access, &evaluation, true).unwrap();
assert_eq!(prompted.source, approvals::ApprovalSource::LocalTty);
assert_eq!(prompted.scope, ApprovalScope::Once);
let once = grants::persist_manual_grant(
&access,
ApprovalScope::Once,
approvals::ApprovalSource::AgentMediated,
&vault,
Some(grants::GrantReceiptContext::synthetic(true)),
)
.unwrap();
let reused_once = decide_access(&access, &evaluation, true).unwrap();
assert_eq!(reused_once.source, approvals::ApprovalSource::Grant);
assert_eq!(reused_once.grant_id, Some(once.id));
std::env::remove_var("WARD_UNSAFE_TEST_APPROVAL");
clear_test_signing_unlock();
}
#[test]
#[serial_test::serial]
fn decide_access_ignores_always_grants_for_suspicious_action_findings() {
let _guard = cwd_lock();
let tempdir = tempfile::tempdir().unwrap();
let vault = setup_test_signing_unlock(tempdir.path(), "demo");
let access = access();
let always = ApprovalDecision {
approved: true,
scope: ApprovalScope::Always,
approved_env: vec!["DATABASE_URL".to_string()],
denied_env: Vec::new(),
source: approvals::ApprovalSource::LocalTty,
grant_id: None,
};
grants::persist_grant(
&access,
&always,
&vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.unwrap();
let session = ApprovalDecision {
scope: ApprovalScope::Session,
..always
};
let session_grant = grants::persist_grant(
&access,
&session,
&vault,
Some(grants::GrantReceiptContext::synthetic(false)),
)
.unwrap()
.unwrap();
let mut evaluation = evaluation(ApprovalMode::Prompt, true);
evaluation.findings.push(detection::Finding::warning(
"action.prompt_injection",
"suspicious action",
));
let reused = decide_access(&access, &evaluation, true).unwrap();
assert_eq!(reused.source, approvals::ApprovalSource::Grant);
assert_eq!(reused.scope, ApprovalScope::Session);
assert_eq!(reused.grant_id, Some(session_grant.id));
clear_test_signing_unlock();
}
#[test]
#[serial_test::serial]
fn decide_access_reports_grant_lookup_errors() {
let _guard = cwd_lock();
let tempdir = tempfile::tempdir().unwrap();
let grants_dir = tempdir.path().join("sessions");
std::fs::create_dir_all(&grants_dir).unwrap();
std::fs::write(grants_dir.join("grants.jsonl"), "{bad-json}\n").unwrap();
std::env::set_var("WARD_HOME", tempdir.path());
assert!(decide_access(&access(), &evaluation(ApprovalMode::Prompt, true), true).is_err());
std::env::remove_var("WARD_HOME");
}
#[test]
#[serial_test::serial]
fn decide_access_continues_when_no_grant_matches() {
let _guard = cwd_lock();
let tempdir = tempfile::tempdir().unwrap();
std::env::set_var("WARD_HOME", tempdir.path());
let decision = decide_access(&access(), &evaluation(ApprovalMode::Auto, false), true)
.expect("empty grant registry should not block auto approval");
assert_eq!(decision.source, approvals::ApprovalSource::PolicyAuto);
std::env::remove_var("WARD_HOME");
}
#[test]
fn gitignore_contains_ignores_comments_and_whitespace() {
let contents = "\n# .env\n .env \n.env.*\n";
assert!(gitignore_contains(contents, ".env"));
assert!(gitignore_contains(contents, ".env.*"));
assert!(!gitignore_contains(contents, ".env.local"));
}
#[test]
fn likely_secret_env_files_finds_variants_except_example() {
let tempdir = tempfile::tempdir().unwrap();
std::fs::write(tempdir.path().join(".env.local"), "SECRET=value\n").unwrap();
std::fs::write(tempdir.path().join(".env.example"), "SECRET=\n").unwrap();
std::fs::write(tempdir.path().join(".env.vault"), "encrypted\n").unwrap();
let files = likely_secret_env_files(tempdir.path()).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with(".env.local"));
}
#[test]
fn likely_secret_env_files_reports_read_dir_errors() {
let tempdir = tempfile::tempdir().unwrap();
let file = tempdir.path().join("not-a-directory");
std::fs::write(&file, "").unwrap();
assert!(likely_secret_env_files(&file).is_err());
}
#[test]
fn check_gitignore_reports_read_errors() {
let tempdir = tempfile::tempdir().unwrap();
std::fs::create_dir(tempdir.path().join(".gitignore")).unwrap();
assert!(check_gitignore(tempdir.path()).is_err());
}
#[test]
fn check_gitignore_allows_missing_file() {
let tempdir = tempfile::tempdir().unwrap();
assert!(check_gitignore(tempdir.path()).is_ok());
}
#[test]
fn check_gitignore_reads_complete_and_partial_files() {
let tempdir = tempfile::tempdir().unwrap();
let gitignore = tempdir.path().join(".gitignore");
std::fs::write(&gitignore, ".env\n.env.*\n").unwrap();
assert!(check_gitignore(tempdir.path()).is_ok());
std::fs::write(&gitignore, ".env\n").unwrap();
assert!(check_gitignore(tempdir.path()).is_ok());
std::fs::write(&gitignore, ".env.*\n").unwrap();
assert!(check_gitignore(tempdir.path()).is_ok());
std::fs::write(&gitignore, ".env\n.env.*\n!.env.vault\n").unwrap();
assert!(check_gitignore(tempdir.path()).is_ok());
}
#[test]
#[serial_test::serial]
fn init_handles_existing_env_example() {
let _guard = cwd_lock();
let tempdir = tempfile::tempdir().unwrap();
let original = std::env::current_dir().unwrap();
std::fs::write(tempdir.path().join(".env.example"), "DATABASE_URL=\n").unwrap();
std::env::set_current_dir(tempdir.path()).unwrap();
let result = init(Some("demo".to_string()), false, true);
std::env::set_current_dir(original).unwrap();
assert!(result.is_ok());
assert!(tempdir
.path()
.join(config::AGENT_INSTRUCTIONS_FILE)
.exists());
}
#[test]
#[serial_test::serial]
fn init_guided_setup_handles_plaintext_env() {
let _guard = cwd_lock();
let tempdir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_var("WARD_UNSAFE_TEST_KEYRING", "1");
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "coverage passphrase");
std::fs::write(
tempdir.path().join(".env"),
"DATABASE_URL=postgres://local\n",
)
.unwrap();
std::env::set_current_dir(tempdir.path()).unwrap();
let result = init(Some("demo".to_string()), false, false);
std::env::set_current_dir(original).unwrap();
std::env::remove_var("WARD_HOME");
std::env::remove_var("WARD_UNSAFE_TEST_KEYRING");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
assert!(result.is_ok());
assert!(tempdir.path().join(".env.example").exists());
assert!(env_file::is_locked_env_file(&tempdir.path().join(".env")).unwrap());
}
#[test]
#[serial_test::serial]
fn doctor_covers_missing_invalid_plaintext_and_alert_error_paths() {
let _guard = cwd_lock();
let original = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
std::env::set_var("WARD_HOME", home.path());
std::env::set_current_dir(project.path()).unwrap();
doctor().unwrap();
std::fs::write(project.path().join(".ward.json"), "{").unwrap();
std::fs::write(
project.path().join(".env"),
"DATABASE_URL=postgres://local\n",
)
.unwrap();
std::fs::write(project.path().join(".env.local"), "SECRET_KEY=value\n").unwrap();
std::fs::write(project.path().join(".gitignore"), ".env\n.env.*\n").unwrap();
doctor().unwrap();
let grants_dir = home.path().join("sessions");
std::fs::create_dir_all(&grants_dir).unwrap();
let now = chrono::Utc::now();
let legacy_grant = grants::ApprovalGrant {
id: uuid::Uuid::new_v4(),
created_at: now,
expires_at: None,
project: "demo".to_string(),
agent: None,
branch: None,
command: "pnpm dev".to_string(),
approved_env: vec!["DATABASE_URL".to_string()],
scope: approvals::ApprovalScope::Always,
uses_remaining: None,
receipt: None,
};
let mut invalid_grant = legacy_grant.clone();
invalid_grant.id = uuid::Uuid::new_v4();
invalid_grant.receipt = Some(crate::approval_receipts::ApprovalReceipt {
payload: crate::approval_receipts::build_payload(
&access(),
invalid_grant.id,
uuid::Uuid::new_v4(),
&["DATABASE_URL".to_string()],
approvals::ApprovalScope::Always,
None,
false,
now,
"missing-signer".to_string(),
),
payload_hash: "bad".to_string(),
signer_key_id: "missing-signer".to_string(),
signature_algorithm: "ed25519".to_string(),
signature: "bad".to_string(),
});
std::fs::write(
grants_dir.join("grants.jsonl"),
format!(
"{}\n{}\n",
serde_json::to_string(&legacy_grant).unwrap(),
serde_json::to_string(&invalid_grant).unwrap()
),
)
.unwrap();
doctor().unwrap();
std::fs::write(grants_dir.join("grants.jsonl"), "{bad-json}\n").unwrap();
doctor().unwrap();
std::fs::create_dir_all(home.path().join("logs/alerts.jsonl")).unwrap();
doctor().unwrap();
std::env::set_current_dir(original).unwrap();
std::env::remove_var("WARD_HOME");
}
#[test]
fn marker_returns_expected_labels() {
assert_eq!(marker(true), "[ok]");
assert_eq!(marker(false), "!");
}
#[test]
fn grant_integrity_messages_cover_ok_legacy_and_invalid_states() {
assert_eq!(
grant_integrity_messages(0, 0),
vec!["[ok] Approval grants are signed and valid.".to_string()]
);
assert_eq!(
grant_integrity_messages(1, 1),
vec![
"! Legacy unsigned approval grants: 1. Re-approve them.".to_string(),
"! Invalid signed approval grants: 1. Revoke and re-approve them.".to_string(),
]
);
}
#[test]
fn grant_status_labels_cover_all_integrity_states() {
assert_eq!(
grant_status_label(grants::GrantIntegrityStatus::Valid),
"valid-signed"
);
assert_eq!(
grant_status_label(grants::GrantIntegrityStatus::Expired),
"expired"
);
assert_eq!(
grant_status_label(grants::GrantIntegrityStatus::LegacyUnsigned),
"legacy-unsigned"
);
assert_eq!(
grant_status_label(grants::GrantIntegrityStatus::Invalid),
"invalid-signature"
);
}
#[test]
fn signing_lookup_messages_cover_warning_variants() {
assert!(
signing_lookup_message(Ok(unlock::RunSigningLookup::Missing))
.contains("No active signing key session")
);
assert!(
signing_lookup_message(Ok(unlock::RunSigningLookup::MaterialUnavailable {
reason: "missing".to_string(),
}))
.contains("missing")
);
assert!(signing_lookup_message(Err(anyhow::anyhow!("boom"))).contains("boom"));
}
#[test]
fn child_exit_formats_and_normalizes_exit_codes() {
let exit = ChildExit::new(7);
assert_eq!(exit.exit_code(), 7);
assert_eq!(exit.to_string(), "child process exited with 7");
let out_of_range = ChildExit::new(300);
assert_eq!(out_of_range.exit_code(), 1);
}
#[test]
fn render_log_events_handles_empty_and_multiline_output() {
assert_eq!(render_log_events(&[]).unwrap(), "");
let events = vec![
serde_json::json!({ "payload": { "eventType": "one" } }),
serde_json::json!({ "payload": { "eventType": "two" } }),
];
let rendered = render_log_events(&events).unwrap();
assert!(rendered.contains("\"eventType\":\"one\""));
assert!(rendered.contains('\n'));
assert!(rendered.contains("\"eventType\":\"two\""));
}
#[test]
fn evaluate_access_combines_detection_and_policy() {
let config = ProjectConfig {
version: 1,
project: "demo".to_string(),
vault: ".env.vault".into(),
presets: Vec::new(),
profiles: std::collections::BTreeMap::new(),
anomaly_detection: config::AnomalyDetectionConfig {
enabled: true,
working_hours_start: 8,
working_hours_end: 20,
max_runs_per_hour_per_grant: 20,
max_branches_per_grant: 3,
},
storage_mode: config::StorageMode::default(),
vault_nonce: String::new(),
backup_exported: false,
recovery_created: false,
};
let mut access = access();
access.action = Some("Run lint".to_string());
let evaluation = evaluate_access(&config, &access);
assert!(evaluation.requires_prompt);
assert!(evaluation
.findings
.iter()
.any(|finding| finding.code == "env.scope_deviation"));
}
#[test]
fn profile_resolution_expands_short_commands_and_validates_conflicts() {
let tempdir = tempfile::tempdir().unwrap();
let config =
ProjectConfig::default_for_dir(tempdir.path(), Some("demo".to_string())).unwrap();
let resolved = resolve_profile(&config, Some("dev"), None, None, Vec::new()).unwrap();
assert_eq!(resolved.command, "pnpm dev");
assert_eq!(resolved.command_args, vec!["pnpm", "dev"]);
assert_eq!(resolved.default_scope, ApprovalScope::Always);
assert!(resolved.env_names.contains(&"DATABASE_URL".to_string()));
let manual = resolve_profile(
&config,
None,
Some("Manual".to_string()),
Some("pnpm lint".to_string()),
vec!["DATABASE_URL".to_string()],
)
.unwrap();
assert_eq!(manual.command_args, vec!["pnpm", "lint"]);
assert_eq!(manual.default_scope, ApprovalScope::Once);
let run_profile = resolve_run_profile(
&config,
Some("migrate"),
None,
Vec::new(),
Vec::new(),
false,
)
.unwrap();
assert_eq!(run_profile.command_args, vec!["pnpm", "payload", "migrate"]);
assert_eq!(run_profile.default_scope, ApprovalScope::Branch);
let missing_profile = resolve_profile(&config, Some("missing"), None, None, Vec::new())
.unwrap_err()
.to_string();
assert!(missing_profile.contains("profile missing is not defined"));
let explicit_run = resolve_run_profile(
&config,
None,
Some("Run".to_string()),
vec!["DATABASE_URL".to_string()],
vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
false,
)
.unwrap();
assert_eq!(explicit_run.command, "sh -c true");
assert!(resolve_profile(
&config,
Some("dev"),
None,
Some("pnpm dev".to_string()),
Vec::new(),
)
.is_err());
assert!(resolve_profile(&config, None, None, None, Vec::new()).is_err());
assert!(resolve_profile(
&config,
None,
None,
Some("pnpm dev".to_string()),
Vec::new(),
)
.is_err());
assert!(resolve_run_profile(
&config,
Some("dev"),
None,
vec!["DATABASE_URL".to_string()],
Vec::new(),
false,
)
.is_err());
assert!(resolve_run_profile(
&config,
Some("missing"),
None,
Vec::new(),
Vec::new(),
false
)
.is_err());
assert!(resolve_run_profile(&config, None, None, Vec::new(), Vec::new(), false).is_err());
assert!(resolve_run_profile(
&config,
None,
None,
Vec::new(),
vec!["pnpm".to_string(), "dev".to_string()],
false,
)
.is_err());
}
#[test]
fn effective_grant_id_prefers_reused_decision_then_persisted_grant() {
let access = access();
let grant = grants::ApprovalGrant {
id: uuid::Uuid::new_v4(),
created_at: chrono::Utc::now(),
expires_at: None,
project: access.project.clone(),
agent: access.agent.clone(),
branch: access.branch.clone(),
command: access.command.clone(),
approved_env: access.env.clone(),
scope: ApprovalScope::Always,
uses_remaining: None,
receipt: None,
};
let decision = ApprovalDecision {
approved: true,
scope: ApprovalScope::Always,
approved_env: access.env.clone(),
denied_env: Vec::new(),
source: approvals::ApprovalSource::LocalTty,
grant_id: None,
};
assert_eq!(effective_grant_id(&decision, Some(&grant)), Some(grant.id));
let reused_id = uuid::Uuid::new_v4();
let reused = ApprovalDecision {
grant_id: Some(reused_id),
source: approvals::ApprovalSource::Grant,
..decision
};
assert_eq!(effective_grant_id(&reused, Some(&grant)), Some(reused_id));
assert_eq!(effective_grant_id(&reused, None), Some(reused_id));
}
#[test]
fn run_unlock_required_json_helper_renders_directly() {
print_run_unlock_required(
&access(),
&evaluation(ApprovalMode::Prompt, true),
Some("unlock_material_unavailable"),
)
.unwrap();
}
#[test]
fn clap_help_renders_all_public_command_metadata() {
let mut command = Cli::command();
let help = command.render_long_help().to_string();
assert!(help.contains("AI secret firewall for local development"));
assert!(help.contains("Select an already registered project"));
assert!(help.contains("Manage stored approval grants"));
for subcommand in [
"setup", "request", "allow", "grants", "approve", "deny", "run", "dev", "migrate",
"logs", "unlock",
] {
let rendered = command
.find_subcommand_mut(subcommand)
.unwrap()
.render_long_help()
.to_string();
assert!(rendered.contains(subcommand));
if subcommand == "run" {
assert!(rendered.contains("Put all Ward flags before --"));
}
}
}
#[test]
fn clap_parses_all_public_command_shapes() {
let request_id = uuid::Uuid::nil().to_string();
let command_sets = vec![
vec![
"ward",
"setup",
"--yes",
"--project",
"demo",
"--source",
".env",
"--vault",
".env.vault",
"--commit-vault",
"--remove-plaintext",
"--unlock-ttl",
"1h",
],
vec!["ward", "init", "--project", "demo", "--force", "--bare"],
vec!["ward", "import", ".env", "--vault", ".env.vault"],
vec![
"ward",
"register",
"demo",
"--path",
".",
"--vault",
".env.vault",
],
vec!["ward", "use", "demo"],
vec!["ward", "projects", "list"],
vec!["ward", "projects", "show", "demo"],
vec![
"ward",
"projects",
"register",
"demo",
"--path",
".",
"--vault",
".env.vault",
],
vec!["ward", "projects", "use", "demo"],
vec!["ward", "projects", "remove", "demo"],
vec!["ward", "env", "list", "--project", "demo"],
vec!["ward", "env", "set", "--project", "demo", "KEY=value"],
vec!["ward", "env", "unset", "--project", "demo", "KEY"],
vec![
"ward",
"env",
"unlock",
"--project",
"demo",
"--output",
".env",
"--force",
],
vec![
"ward",
"env",
"lock",
"--project",
"demo",
"--source",
".env",
],
vec![
"ward",
"env",
"export",
"--project",
"demo",
"--output",
".env.export",
"--force",
],
vec![
"ward",
"env",
"export",
"--project",
"demo",
"--unsafe-stdout",
],
vec![
"ward",
"request",
"--profile",
"dev",
"--agent",
"codex",
"--branch",
"main",
"--action",
"Run dev",
"--command",
"pnpm dev",
"--env",
"DATABASE_URL",
"--json",
"--no-prompt",
],
vec![
"ward",
"allow",
"--profile",
"dev",
"--scope",
"always",
"--agent",
"codex",
"--branch",
"main",
"--command",
"pnpm dev",
"--env",
"DATABASE_URL",
],
vec!["ward", "grants", "list"],
vec!["ward", "grants", "revoke", &request_id],
vec!["ward", "grants", "prune"],
vec![
"ward",
"approve",
&request_id,
"--scope",
"once",
"--confirm-critical",
"--agent-mediated",
],
vec!["ward", "deny", &request_id, "--agent-mediated"],
vec![
"ward",
"run",
"--profile",
"dev",
"--project",
"demo",
"--agent",
"codex",
"--branch",
"main",
"--action",
"Run dev",
"--env",
"DATABASE_URL",
"--json",
"--no-prompt",
"--",
"pnpm",
"dev",
],
vec![
"ward",
"dev",
"--agent",
"codex",
"--branch",
"main",
"--json",
"--no-prompt",
],
vec![
"ward",
"migrate",
"--agent",
"codex",
"--branch",
"main",
"--json",
"--no-prompt",
],
vec!["ward", "doctor"],
vec!["ward", "logs", "requests"],
vec!["ward", "logs", "view", "requests"],
vec!["ward", "logs", "verify", "requests", "--full"],
vec![
"ward",
"logs",
"export",
"requests",
"--output",
"requests.jsonl",
"--force",
],
vec!["ward", "logs", "unlock", "--ttl", "15m"],
vec!["ward", "dashboard"],
vec!["ward", "dashboard", "start", "--port", "7780", "--no-open"],
vec!["ward", "dashboard", "start", "--foreground", "--json"],
vec!["ward", "dashboard", "stop", "--all", "--json"],
vec!["ward", "dashboard", "stop", "--pid", "1234"],
vec!["ward", "dashboard", "stop", "--port", "7780"],
vec!["ward", "dashboard", "status", "--json"],
vec!["ward", "dashboard", "tui"],
vec!["ward", "edit"],
vec!["ward", "unlock", "--ttl", "1h"],
vec!["ward", "lock"],
vec![
"ward",
"teardown",
"--project",
"demo",
"--export",
".env.export",
"--yes",
"--restore-env",
],
];
for args in command_sets {
assert!(Cli::try_parse_from(args).is_ok());
}
}
#[test]
fn debug_formats_all_cli_command_variants() {
let request_id = uuid::Uuid::nil();
let commands = vec![
format!(
"{:?}",
Cli {
command: Commands::Lock
}
),
format!(
"{:?}",
Commands::Init {
project: Some("demo".to_string()),
force: true,
bare: false,
}
),
format!(
"{:?}",
Commands::Import {
source: ".env".into(),
vault: Some(".env.vault".into()),
}
),
format!(
"{:?}",
Commands::Register {
project: "demo".to_string(),
path: Some(".".into()),
vault: Some(".env.vault".into()),
}
),
format!(
"{:?}",
Commands::Use {
project: "demo".to_string(),
}
),
format!(
"{:?}",
Commands::Request {
profile: None,
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: Some("main".to_string()),
action: Some("Run".to_string()),
command: Some("pnpm dev".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
json: true,
no_prompt: true,
}
),
format!(
"{:?}",
Commands::Allow {
profile: None,
scope: Some(ApprovalScope::Always),
agent: Some("codex".to_string()),
branch: Some("main".to_string()),
command: Some("pnpm dev".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
}
),
format!(
"{:?}",
Commands::Grants {
command: GrantsCommand::List,
}
),
format!(
"{:?}",
Commands::Approve {
request_id,
scope: ApprovalScope::Session,
confirm_critical: true,
agent_mediated: true,
json: false,
}
),
format!(
"{:?}",
Commands::Deny {
request_id,
agent_mediated: true,
json: false,
}
),
format!(
"{:?}",
Commands::Run {
profile: None,
project: Some("demo".to_string()),
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: Some("main".to_string()),
action: Some("Run".to_string()),
env_names: vec!["DATABASE_URL".to_string()],
json: false,
no_prompt: false,
command: vec!["pnpm".to_string(), "dev".to_string()],
}
),
format!(
"{:?}",
Commands::Setup {
yes: true,
project: Some("demo".to_string()),
source: ".env".into(),
vault: ".env.vault".into(),
commit_vault: true,
ignore_vault: false,
remove_plaintext: true,
keep_plaintext: false,
unlock_ttl: "8h".to_string(),
no_unlock: false,
}
),
format!(
"{:?}",
Commands::Dev {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: Some("main".to_string()),
json: false,
no_prompt: false,
}
),
format!(
"{:?}",
Commands::Migrate {
agent: Some("codex".to_string()),
agent_key_id: None,
worktree: None,
git_remote: None,
commit: None,
branch: Some("main".to_string()),
json: false,
no_prompt: false,
}
),
format!("{:?}", Commands::Doctor),
format!(
"{:?}",
Commands::Logs {
command: Some(LogsCommand::View {
kind: LogKind::Requests,
}),
kind: Some(LogKind::Requests),
}
),
format!("{:?}", Commands::Edit),
format!(
"{:?}",
Commands::Unlock {
ttl: "1h".to_string(),
mode: None,
}
),
format!("{:?}", Commands::Lock),
format!(
"{:?}",
GrantsCommand::Revoke {
grant_id: request_id
}
),
format!("{:?}", GrantsCommand::Prune),
format!(
"{:?}",
LogsCommand::Verify {
kind: None,
full: false,
}
),
format!(
"{:?}",
LogsCommand::Unlock {
ttl: "15m".to_string(),
}
),
format!(
"{:?}",
Commands::Dashboard {
command: Some(DashboardCommand::Status { json: true }),
}
),
format!(
"{:?}",
DashboardCommand::Start {
port: Some(7777),
no_open: true,
foreground: false,
json: true,
}
),
format!(
"{:?}",
DashboardCommand::Stop {
all: true,
pid: None,
port: None,
json: false,
}
),
format!("{:?}", DashboardCommand::Tui),
];
assert_eq!(commands.len(), 27);
for value in commands {
assert!(!value.is_empty());
}
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(path, permissions).unwrap();
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) {}
}