Sprint B.6: CLI guardrails — --dry-run, protected_repos, VULP_AGENT marker

- global --dry-run: sync/release/init/collab/license print 'DRY RUN: would...'
  and exit 0 (no auth needed for previews where possible)
- config protected_repos (name/basename, case-insensitive): mutating commands
  exit 1 on listed repos unless --force; checked before dry-run
- VULP_AGENT env: activity::log centrally appends '· via agent' to detail,
  covering both CLI and TUI writers
- sync: unknown --action now rejected before touching any repo
This commit is contained in:
dualfroz committed 2026-07-06 15:43:14 +02:00
1 parent f1432d1cd3
commit 8119cdeba7
8 files changed
+133 -14

No files matched your search

+14
View File
@@ -23,6 +23,20 @@ vulp <command> [--json] # non-interactive (scripts/AI)
- Mutating commands write to the same `~/.vulp/activity.log` as the TUI, - Mutating commands write to the same `~/.vulp/activity.log` as the TUI,
so CLI actions appear in the TUI's Overview feed. so CLI actions appear in the TUI's Overview feed.
## Guardrails (for AI agents especially)
- **`--dry-run`** (global flag): mutating commands (`sync`, `release`,
`init`, `repo collab`, `repo license`) print `DRY RUN: would ...` lines
and exit 0 without changing anything. Read commands ignore it.
- **`protected_repos`** (config, comma-separated in `vulp config`): mutating
commands refuse to touch listed repos (exit 1) unless **`--force`** is
passed. Matching is by display label or dir basename, case-insensitive.
The protection check runs before `--dry-run`, so a dry run also reveals it.
- **`VULP_AGENT`** (env var, any non-empty value): every activity-log entry
written while it's set gets a `· via agent` marker in its detail, so human
and agent actions are distinguishable in `vulp activity` and the TUI feed.
AI agents driving vulp should always set it.
## Workspace (local, no network) ## Workspace (local, no network)
``` ```
+11 -1
View File
@@ -55,7 +55,17 @@ fn log_path() -> PathBuf {
/// the Activity tab's commit feed (sourced straight from git log) covers /// the Activity tab's commit feed (sourced straight from git log) covers
/// separately and accurately regardless of how it happened. /// separately and accurately regardless of how it happened.
pub fn log(entry: &ActivityEntry) { pub fn log(entry: &ActivityEntry) {
let Ok(line) = serde_json::to_string(entry) else { return }; // Executor marker: AI agents set VULP_AGENT so their actions are
// distinguishable from the human's in the shared log.
let mut entry = entry.clone();
if std::env::var("VULP_AGENT").map(|v| !v.is_empty()).unwrap_or(false) {
entry.detail = if entry.detail.is_empty() {
"via agent".into()
} else {
format!("{} · via agent", entry.detail)
};
}
let Ok(line) = serde_json::to_string(&entry) else { return };
let path = log_path(); let path = log_path();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
+4 -1
View File
@@ -22,6 +22,9 @@ pub async fn run(cli: Cli) -> Result<()> {
Some(Commands::Auth(args)) => crate::auth::run(args, &cfg).await, Some(Commands::Auth(args)) => crate::auth::run(args, &cfg).await,
// ── everything else: non-interactive CLI ── // ── everything else: non-interactive CLI ──
Some(cmd) => crate::cli_cmds::run(cmd, cli.json, &cfg).await, Some(cmd) => {
let guard = crate::cli_cmds::Guard { dry_run: cli.dry_run, force: cli.force };
crate::cli_cmds::run(cmd, cli.json, guard, &cfg).await
}
} }
} }
+7
View File
@@ -19,6 +19,13 @@ pub struct Cli {
/// Machine-readable JSON output on stdout (list/status/stats/... commands) /// Machine-readable JSON output on stdout (list/status/stats/... commands)
#[arg(long, global = true)] #[arg(long, global = true)]
pub json: bool, pub json: bool,
/// Print what a mutating command (sync/release/init/collab/license)
/// would do without doing it
#[arg(long, global = true)]
pub dry_run: bool,
/// Proceed on repos listed in config `protected_repos`
#[arg(long, global = true)]
pub force: bool,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
+36 -5
View File
@@ -14,18 +14,26 @@ use crate::cli::Commands;
use crate::config::AppConfig; use crate::config::AppConfig;
use anyhow::Result; use anyhow::Result;
/// Global guardrail flags (`--dry-run`, `--force`) threaded into the
/// mutating commands; read commands ignore them.
#[derive(Clone, Copy)]
pub(crate) struct Guard {
pub dry_run: bool,
pub force: bool,
}
/// Dispatches one parsed subcommand. Only called for commands that aren't /// Dispatches one parsed subcommand. Only called for commands that aren't
/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`). /// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`).
pub async fn run(cmd: Commands, json: bool, cfg: &AppConfig) -> Result<()> { pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> {
match cmd { match cmd {
Commands::List { dirty, behind, ahead, lang, project } => Commands::List { dirty, behind, ahead, lang, project } =>
workspace::list(cfg, json, dirty, behind, ahead, lang, project), workspace::list(cfg, json, dirty, behind, ahead, lang, project),
Commands::Status { repo } => workspace::status(cfg, json, &repo), Commands::Status { repo } => workspace::status(cfg, json, &repo),
Commands::Scan => workspace::scan_cmd(cfg, json), Commands::Scan => workspace::scan_cmd(cfg, json),
Commands::Sync { repos, all, project, action, message } => Commands::Sync { repos, all, project, action, message } =>
workspace::sync(cfg, repos, all, project, &action, message), workspace::sync(cfg, guard, repos, all, project, &action, message),
Commands::Release { repo, tag, notes } => Commands::Release { repo, tag, notes } =>
remote::release(cfg, &repo, &tag, &notes).await, remote::release(cfg, guard, &repo, &tag, &notes).await,
Commands::Stats { period } => workspace::stats(cfg, json, &period), Commands::Stats { period } => workspace::stats(cfg, json, &period),
Commands::Activity { limit, kind } => workspace::activity(json, limit, kind), Commands::Activity { limit, kind } => workspace::activity(json, limit, kind),
@@ -34,9 +42,9 @@ pub async fn run(cmd: Commands, json: bool, cfg: &AppConfig) -> Result<()> {
Commands::Pr { action } => remote::pr(cfg, json, action).await, Commands::Pr { action } => remote::pr(cfg, json, action).await,
Commands::Issue { action } => remote::issue(cfg, json, action).await, Commands::Issue { action } => remote::issue(cfg, json, action).await,
Commands::Repo { action } => remote::repo_cmd(cfg, json, action).await, Commands::Repo { action } => remote::repo_cmd(cfg, json, guard, action).await,
Commands::Init { name, private, description } => Commands::Init { name, private, description } =>
remote::init(cfg, &name, private, &description).await, remote::init(cfg, guard, &name, private, &description).await,
Commands::Accounts => remote::accounts(cfg, json), Commands::Accounts => remote::accounts(cfg, json),
Commands::Refresh => remote::refresh(cfg, json).await, Commands::Refresh => remote::refresh(cfg, json).await,
@@ -96,6 +104,29 @@ pub(crate) fn default_provider(cfg: &AppConfig) -> (crate::auth::Account, Box<dy
} }
} }
/// Whether `r` is listed in config `protected_repos` (display name or dir
/// basename, case-insensitive).
pub(crate) fn is_protected(cfg: &AppConfig, r: &crate::status::RepoSummary) -> bool {
let name = r.name.to_lowercase();
let base = r.path.file_name().map(|b| b.to_string_lossy().to_lowercase()).unwrap_or_default();
cfg.protected_repos.iter().any(|p| {
let p = p.trim().to_lowercase();
p == name || p == base
})
}
/// Exits 1 when any target is protected and `--force` wasn't passed.
pub(crate) fn guard_protected(cfg: &AppConfig, force: bool, targets: &[&crate::status::RepoSummary]) {
if force { return; }
let blocked: Vec<&str> = targets.iter()
.filter(|r| is_protected(cfg, r))
.map(|r| r.name.as_str())
.collect();
if !blocked.is_empty() {
die(1, &format!("protected repos: {} — pass --force to proceed", blocked.join(", ")));
}
}
/// Logs one vulp-performed action to `~/.vulp/activity.log` (same log the /// Logs one vulp-performed action to `~/.vulp/activity.log` (same log the
/// TUI writes, so CLI actions show up in the Overview feed too). /// TUI writes, so CLI actions show up in the Overview feed too).
pub(crate) fn log_activity(kind: crate::activity::ActivityKind, repo: &str, detail: String) { pub(crate) fn log_activity(kind: crate::activity::ActivityKind, repo: &str, detail: String) {
+33 -6
View File
@@ -78,8 +78,13 @@ pub async fn issue(cfg: &AppConfig, json: bool, cmd: IssueCmd) -> Result<()> {
} }
} }
pub async fn release(cfg: &AppConfig, repo: &str, tag: &str, notes: &str) -> Result<()> { pub async fn release(cfg: &AppConfig, guard: super::Guard, repo: &str, tag: &str, notes: &str) -> Result<()> {
let r = find_repo(cfg, repo); let r = find_repo(cfg, repo);
super::guard_protected(cfg, guard.force, &[&r]);
if guard.dry_run {
println!("DRY RUN: would tag {} as {tag}, push the tag, and publish a provider release", r.name);
return Ok(());
}
let msg = if notes.is_empty() { tag } else { notes }; let msg = if notes.is_empty() { tag } else { notes };
git_run(&r.path, &["tag", "-a", tag, "-m", msg]); git_run(&r.path, &["tag", "-a", tag, "-m", msg]);
if !git_run_ok(&r.path, &["push", "origin", tag]) { if !git_run_ok(&r.path, &["push", "origin", tag]) {
@@ -103,7 +108,7 @@ pub async fn release(cfg: &AppConfig, repo: &str, tag: &str, notes: &str) -> Res
Ok(()) Ok(())
} }
pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> { pub async fn repo_cmd(cfg: &AppConfig, json: bool, guard: super::Guard, cmd: RepoCmd) -> Result<()> {
match cmd { match cmd {
RepoCmd::Url { repo } => { RepoCmd::Url { repo } => {
let r = find_repo(cfg, &repo); let r = find_repo(cfg, &repo);
@@ -162,16 +167,21 @@ pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> {
die(2, "role must be pull | push | admin"); die(2, "role must be pull | push | admin");
} }
let r = find_repo(cfg, &repo); let r = find_repo(cfg, &repo);
let (_, provider) = default_provider(cfg); super::guard_protected(cfg, guard.force, &[&r]);
let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string())
.unwrap_or_else(|| r.name.clone()); .unwrap_or_else(|| r.name.clone());
if guard.dry_run {
println!("DRY RUN: would invite {user} to {repo_basename} ({role})");
return Ok(());
}
let (_, provider) = default_provider(cfg);
match provider.add_collaborator(&repo_basename, &user, &role).await { match provider.add_collaborator(&repo_basename, &user, &role).await {
Ok(()) => { println!("invited {user} to {repo_basename} ({role})"); Ok(()) } Ok(()) => { println!("invited {user} to {repo_basename} ({role})"); Ok(()) }
Err(e) => die(1, &format!("invite failed: {e:#}")), Err(e) => die(1, &format!("invite failed: {e:#}")),
} }
} }
RepoCmd::License { repos, all, kind, holder } => { RepoCmd::License { repos, all, kind, holder } => {
license(cfg, json, repos, all, kind, holder) license(cfg, json, guard, repos, all, kind, holder)
} }
} }
} }
@@ -179,7 +189,7 @@ pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> {
/// `vulp repo license` — writes a LICENSE into each target repo that lacks /// `vulp repo license` — writes a LICENSE into each target repo that lacks
/// one. Explicit names that already have a license are reported as skipped, /// one. Explicit names that already have a license are reported as skipped,
/// not errors; `--all` targets every unlicensed workspace repo. /// not errors; `--all` targets every unlicensed workspace repo.
fn license(cfg: &AppConfig, json: bool, repos: Vec<String>, all: bool, fn license(cfg: &AppConfig, json: bool, guard: super::Guard, repos: Vec<String>, all: bool,
kind: Option<String>, holder: Option<String>) -> Result<()> { kind: Option<String>, holder: Option<String>) -> Result<()> {
use crate::license::{LicenseKind, WriteOutcome}; use crate::license::{LicenseKind, WriteOutcome};
@@ -211,6 +221,18 @@ fn license(cfg: &AppConfig, json: bool, repos: Vec<String>, all: bool,
}).collect() }).collect()
}; };
super::guard_protected(cfg, guard.force, &targets);
if guard.dry_run {
for r in &targets {
if r.has_license {
println!("DRY RUN: would skip {} (already has a license)", r.name);
} else {
println!("DRY RUN: would write {} LICENSE to {} (holder: {holder})", kind.label(), r.name);
}
}
return Ok(());
}
let year = chrono::Datelike::year(&chrono::Utc::now()); let year = chrono::Datelike::year(&chrono::Utc::now());
let (mut written, mut skipped, mut failed) = (Vec::new(), Vec::new(), Vec::new()); let (mut written, mut skipped, mut failed) = (Vec::new(), Vec::new(), Vec::new());
for r in targets { for r in targets {
@@ -240,11 +262,16 @@ fn license(cfg: &AppConfig, json: bool, repos: Vec<String>, all: bool,
Ok(()) Ok(())
} }
pub async fn init(cfg: &AppConfig, name: &str, private: bool, description: &str) -> Result<()> { pub async fn init(cfg: &AppConfig, guard: super::Guard, name: &str, private: bool, description: &str) -> Result<()> {
let path = cfg.workspace_root.join(name); let path = cfg.workspace_root.join(name);
if path.exists() { if path.exists() {
die(1, &format!("{} already exists", path.display())); die(1, &format!("{} already exists", path.display()));
} }
if guard.dry_run {
println!("DRY RUN: would init {}, create a {} repo on the provider, and push",
path.display(), if private { "private" } else { "public" });
return Ok(());
}
let (_, provider) = default_provider(cfg); let (_, provider) = default_provider(cfg);
std::fs::create_dir_all(&path)?; std::fs::create_dir_all(&path)?;
+15 -1
View File
@@ -89,7 +89,7 @@ pub fn scan_cmd(cfg: &AppConfig, json: bool) -> Result<()> {
} }
pub fn sync( pub fn sync(
cfg: &AppConfig, cfg: &AppConfig, guard: super::Guard,
repos: Vec<String>, all: bool, project: Option<String>, repos: Vec<String>, all: bool, project: Option<String>,
action: &str, message: Option<String>, action: &str, message: Option<String>,
) -> Result<()> { ) -> Result<()> {
@@ -120,11 +120,25 @@ pub fn sync(
}; };
let action = action.replace('-', "_"); // commit-push → commit_push let action = action.replace('-', "_"); // commit-push → commit_push
if !["pull", "push", "commit_push"].contains(&action.as_str()) {
die(2, &format!("unknown action '{action}' (pull | push | commit-push)"));
}
if action == "commit_push" && message.is_none() { if action == "commit_push" && message.is_none() {
die(2, "--message is required for commit-push"); die(2, "--message is required for commit-push");
} }
let msg = message.unwrap_or_default(); let msg = message.unwrap_or_default();
super::guard_protected(cfg, guard.force, &targets);
if guard.dry_run {
for r in &targets {
match action.as_str() {
"commit_push" => println!("DRY RUN: would commit+push {} (message: {msg})", r.name),
a => println!("DRY RUN: would {a} {}", r.name),
}
}
return Ok(());
}
let mut failed = 0usize; let mut failed = 0usize;
for r in &targets { for r in &targets {
eprintln!("── {} [{}]", r.name, action); eprintln!("── {} [{}]", r.name, action);
+13
View File
@@ -44,6 +44,10 @@ pub struct AppConfig {
/// Default template for the add-LICENSE action: "mit" or "apache-2.0". /// Default template for the add-LICENSE action: "mit" or "apache-2.0".
#[serde(default = "default_license_kind")] #[serde(default = "default_license_kind")]
pub default_license: String, pub default_license: String,
/// Repos (display name or dir basename, case-insensitive) that mutating
/// CLI commands refuse to touch unless `--force` is passed.
#[serde(default)]
pub protected_repos: Vec<String>,
} }
fn default_license_kind() -> String { "mit".into() } fn default_license_kind() -> String { "mit".into() }
@@ -82,6 +86,7 @@ impl Default for AppConfig {
show_locs: false, show_locs: false,
license_holder: String::new(), license_holder: String::new(),
default_license: default_license_kind(), default_license: default_license_kind(),
protected_repos: vec![],
} }
} }
} }
@@ -107,6 +112,7 @@ impl AppConfig {
("workspace_groups", self.workspace_groups.join(", ")), ("workspace_groups", self.workspace_groups.join(", ")),
("license_holder", self.license_holder.clone()), ("license_holder", self.license_holder.clone()),
("default_license", self.default_license.clone()), ("default_license", self.default_license.clone()),
("protected_repos", self.protected_repos.join(", ")),
] ]
} }
@@ -130,6 +136,13 @@ impl AppConfig {
"vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value), "vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value),
"license_holder" => self.license_holder = value, "license_holder" => self.license_holder = value,
"default_license" => self.default_license = value, "default_license" => self.default_license = value,
"protected_repos" => {
self.protected_repos = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
"workspace_groups" => { "workspace_groups" => {
self.workspace_groups = value self.workspace_groups = value
.split(',') .split(',')