Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .auths/ci-bundle.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@
],
"bundle_timestamp": "2026-06-19T14:19:08.262292Z",
"max_valid_for_secs": 31536000
}
}
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Exclude the .recurve/ working area from every hook (commit and push stages).
# It is a local scratch/notes area (shell, markdown, JSON dumps), not workspace
# source, so file hooks (whitespace, EOF, yaml/toml) must not touch it.
exclude: '^\.recurve/'

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
Expand Down
93 changes: 76 additions & 17 deletions crates/auths-cli/src/commands/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,37 @@ pub fn ensure_agent_running(quiet: bool) -> Result<bool> {
Err(anyhow!("Failed to start agent within 2 seconds"))
}

pub fn handle_agent(cmd: AgentCommand) -> Result<()> {
/// The name of a per-machine daemon operation, or `None` for a store operation.
///
/// Daemon operations (start/stop/status/install-service/uninstall-service) act
/// on the per-machine agent and cannot be scoped by `--repo`. Store operations
/// (env/lock/unlock) read repo-scoped paths.
fn daemon_op_name(cmd: &AgentSubcommand) -> Option<&'static str> {
match cmd {
AgentSubcommand::Start { .. } => Some("start"),
AgentSubcommand::Stop => Some("stop"),
AgentSubcommand::Status => Some("status"),
AgentSubcommand::InstallService { .. } => Some("install-service"),
AgentSubcommand::UninstallService => Some("uninstall-service"),
AgentSubcommand::Env { .. } | AgentSubcommand::Lock | AgentSubcommand::Unlock { .. } => {
None
}
}
}

pub fn handle_agent(cmd: AgentCommand, repo: Option<PathBuf>) -> Result<()> {
// Daemon operations are per-machine; `--repo` cannot scope them, so reject
// it rather than silently operate on the global agent.
if let Some(op) = daemon_op_name(&cmd.command)
&& repo.is_some()
{
anyhow::bail!(
"`--repo` is not supported for `auths agent {}`; the agent daemon is per-machine \
and is selected by AUTHS_HOME, not by repository.",
op
);
}

match cmd.command {
AgentSubcommand::Start {
socket,
Expand All @@ -171,15 +201,16 @@ pub fn handle_agent(cmd: AgentCommand) -> Result<()> {
} => start_agent(socket, foreground, &timeout, false),
AgentSubcommand::Stop => stop_agent(),
AgentSubcommand::Status => show_status(),
AgentSubcommand::Env { shell } => output_env(shell),
AgentSubcommand::Lock => lock_agent(),
AgentSubcommand::Unlock { agent_key_alias } => unlock_agent(&agent_key_alias),
AgentSubcommand::InstallService {
dry_run,
force,
manager,
} => service::install_service(dry_run, force, manager),
AgentSubcommand::UninstallService => service::uninstall_service(),
// Store operations read repo-scoped paths; thread `--repo` through.
AgentSubcommand::Env { shell } => output_env(shell, repo),
AgentSubcommand::Lock => lock_agent(repo),
AgentSubcommand::Unlock { agent_key_alias } => unlock_agent(&agent_key_alias, repo),
}
}

Expand Down Expand Up @@ -219,6 +250,34 @@ fn get_auths_dir() -> Result<PathBuf> {
auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
}

/// Resolve the agent's storage directory, honoring a `--repo` override.
///
/// `None` preserves the default (`AUTHS_HOME` / `~/.auths`); `Some(path)`
/// scopes the agent's socket/pid/env files to that registry.
///
/// Args:
/// * `repo`: The optional `--repo` override.
///
/// Usage:
/// ```ignore
/// let dir = get_auths_dir_for_repo(ctx.repo_path.clone())?;
/// ```
fn get_auths_dir_for_repo(repo: Option<PathBuf>) -> Result<PathBuf> {
match repo {
Some(_) => auths_sdk::storage_layout::resolve_repo_path(repo)
.context("Failed to resolve the repository path for the agent store"),
None => get_auths_dir(),
}
}

fn get_socket_path_for_repo(repo: Option<PathBuf>) -> Result<PathBuf> {
Ok(get_auths_dir_for_repo(repo)?.join(DEFAULT_SOCKET_NAME))
}

fn get_pid_file_path_for_repo(repo: Option<PathBuf>) -> Result<PathBuf> {
Ok(get_auths_dir_for_repo(repo)?.join(PID_FILE_NAME))
}

/// Get the default socket path.
pub fn get_default_socket_path() -> Result<PathBuf> {
Ok(get_auths_dir()?.join(DEFAULT_SOCKET_NAME))
Expand Down Expand Up @@ -465,9 +524,9 @@ fn show_status() -> Result<()> {
Ok(())
}

fn output_env(shell: ShellFormat) -> Result<()> {
let socket_path = get_default_socket_path()?;
let pid_path = get_pid_file_path()?;
fn output_env(shell: ShellFormat, repo: Option<PathBuf>) -> Result<()> {
let socket_path = get_socket_path_for_repo(repo.clone())?;
let pid_path = get_pid_file_path_for_repo(repo)?;

let pid = read_pid_file(&pid_path)?;
let running = pid.map(is_process_running).unwrap_or(false);
Expand Down Expand Up @@ -501,16 +560,16 @@ fn output_env(shell: ShellFormat) -> Result<()> {
}

#[cfg(unix)]
fn lock_agent() -> Result<()> {
let pid_path = get_pid_file_path()?;
fn lock_agent(repo: Option<PathBuf>) -> Result<()> {
let pid_path = get_pid_file_path_for_repo(repo.clone())?;
let pid = read_pid_file(&pid_path)?;
let running = pid.map(is_process_running).unwrap_or(false);

if !running {
return Err(anyhow!("Agent is not running"));
}

let socket_path = get_default_socket_path()?;
let socket_path = get_socket_path_for_repo(repo)?;
auths_sdk::agent_core::remove_all_identities(&socket_path)
.map_err(|e| anyhow!("Failed to lock agent: {}", e))?;

Expand All @@ -521,23 +580,23 @@ fn lock_agent() -> Result<()> {
}

#[cfg(not(unix))]
fn lock_agent() -> Result<()> {
fn lock_agent(_repo: Option<PathBuf>) -> Result<()> {
Err(anyhow!(
"Agent lock is not supported on this platform (requires Unix domain sockets)"
))
}

#[cfg(unix)]
fn unlock_agent(key_alias: &str) -> Result<()> {
let pid_path = get_pid_file_path()?;
fn unlock_agent(key_alias: &str, repo: Option<PathBuf>) -> Result<()> {
let pid_path = get_pid_file_path_for_repo(repo.clone())?;
let pid = read_pid_file(&pid_path)?;
let running = pid.map(is_process_running).unwrap_or(false);

if !running {
return Err(anyhow!("Agent is not running"));
}

let socket_path = get_default_socket_path()?;
let socket_path = get_socket_path_for_repo(repo)?;

let keychain = auths_sdk::keychain::get_platform_keychain()
.map_err(|e| anyhow!("Failed to get platform keychain: {}", e))?;
Expand Down Expand Up @@ -571,15 +630,15 @@ fn unlock_agent(key_alias: &str) -> Result<()> {
}

#[cfg(not(unix))]
fn unlock_agent(_key_alias: &str) -> Result<()> {
fn unlock_agent(_key_alias: &str, _repo: Option<PathBuf>) -> Result<()> {
Err(anyhow!(
"Agent unlock is not supported on this platform (requires Unix domain sockets)"
))
}

impl crate::commands::executable::ExecutableCommand for AgentCommand {
fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
handle_agent(self.clone())
fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
handle_agent(self.clone(), ctx.repo_path.clone())
}
}

Expand Down
55 changes: 44 additions & 11 deletions crates/auths-cli/src/commands/agent/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,49 @@
use anyhow::{Context, Result, anyhow};
use clap::ValueEnum;
use std::fs;
use std::path::PathBuf;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};

use super::{get_default_socket_path, get_log_file_path};

/// Gate overwriting an already-installed service unit.
///
/// `--force` is explicit consent and proceeds. Otherwise this confirms interactively (a service unit
/// controls what runs as the agent, so replacing it is a privileged change) and refuses when there is no
/// terminal to confirm on.
///
/// Args:
/// * `path`: The service unit that already exists.
/// * `force`: Whether `--force` was passed.
///
/// Usage:
/// ```ignore
/// if unit_path.exists() { confirm_overwrite(&unit_path, force)?; }
/// ```
fn confirm_overwrite(path: &Path, force: bool) -> Result<()> {
if force {
return Ok(());
}
if !std::io::stdin().is_terminal() {
return Err(anyhow!(
"Service already installed at {}. Use --force to overwrite.",
path.display()
));
}
let confirmed = dialoguer::Confirm::new()
.with_prompt(format!(
"A service is already installed at {}. Overwrite it? This replaces what runs as your agent.",
path.display()
))
.default(false)
.interact()
.context("Failed to read overwrite confirmation")?;
if !confirmed {
return Err(anyhow!("Aborted: the existing service was left unchanged."));
}
Ok(())
}

/// Service manager type for platform-specific service installation.
#[derive(ValueEnum, Clone, Debug, PartialEq)]
pub enum ServiceManager {
Expand Down Expand Up @@ -168,11 +207,8 @@ fn install_launchd_service(dry_run: bool, force: bool) -> Result<()> {
return Ok(());
}

if plist_path.exists() && !force {
return Err(anyhow!(
"Service already installed at {}. Use --force to overwrite.",
plist_path.display()
));
if plist_path.exists() {
confirm_overwrite(&plist_path, force)?;
}

if let Some(parent) = plist_path.parent() {
Expand Down Expand Up @@ -204,11 +240,8 @@ fn install_systemd_service(dry_run: bool, force: bool) -> Result<()> {
return Ok(());
}

if unit_path.exists() && !force {
return Err(anyhow!(
"Service already installed at {}. Use --force to overwrite.",
unit_path.display()
));
if unit_path.exists() {
confirm_overwrite(&unit_path, force)?;
}

if let Some(parent) = unit_path.parent() {
Expand Down
9 changes: 9 additions & 0 deletions crates/auths-cli/src/commands/artifact/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ pub enum ArtifactSubcommand {
#[arg(long = "sig-output", value_name = "PATH")]
sig_output: Option<PathBuf>,

/// Overwrite the --sig-output file if it already exists.
#[arg(long)]
force: bool,

/// Local alias of the identity key (used for signing). Omit for CI device-only signing.
#[arg(
long,
Expand Down Expand Up @@ -427,6 +431,7 @@ pub fn handle_artifact(
ArtifactSubcommand::Sign {
file,
sig_output,
force,
key,
device_key,
expires_in,
Expand Down Expand Up @@ -561,6 +566,8 @@ pub fn handle_artifact(
p
});

sign::ensure_writable(&output_path, force)?;

std::fs::write(&output_path, &final_json)
.with_context(|| format!("Failed to write signature to {:?}", output_path))?;

Expand Down Expand Up @@ -596,6 +603,7 @@ pub fn handle_artifact(
env_config,
&log,
allow_unlogged,
force,
)
}
}
Expand Down Expand Up @@ -639,6 +647,7 @@ pub fn handle_artifact(
env_config,
&None,
false,
false,
)?;
default_sig
}
Expand Down
61 changes: 61 additions & 0 deletions crates/auths-cli/src/commands/artifact/sign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ use super::file::FileArtifact;
use super::{dsse_pae, merge_transparency, submit_to_log};
use crate::factories::storage::build_auths_context;

/// Refuse to write over an existing file unless the caller opts in.
///
/// The signature output path is taken verbatim from the user (or auto-derived next to the
/// artifact), so an unguarded write would silently replace whatever already lives there.
///
/// Args:
/// * `path`: the path the signature would be written to.
/// * `force`: when true, an existing file is allowed to be overwritten.
///
/// Usage:
/// ```ignore
/// ensure_writable(&output_path, force)?;
/// std::fs::write(&output_path, &final_json)?;
/// ```
pub(super) fn ensure_writable(path: &Path, force: bool) -> Result<()> {
if !force && path.exists() {
anyhow::bail!(
"refusing to overwrite existing file {:?}; pass --force to overwrite",
path
);
}
Ok(())
}

/// Execute the `artifact sign` command.
#[allow(clippy::too_many_arguments)]
pub fn handle_sign(
Expand All @@ -28,6 +52,7 @@ pub fn handle_sign(
env_config: &EnvironmentConfig,
log: &Option<String>,
allow_unlogged: bool,
force: bool,
) -> Result<()> {
let repo_path = auths_sdk::storage_layout::resolve_repo_path(repo_opt)?;

Expand Down Expand Up @@ -87,6 +112,8 @@ pub fn handle_sign(
p
});

ensure_writable(&output_path, force)?;

std::fs::write(&output_path, &final_json)
.with_context(|| format!("Failed to write signature to {:?}", output_path))?;

Expand Down Expand Up @@ -122,3 +149,37 @@ fn print_authorization_scope(attestation_json: &str) {
}
}
}

#[cfg(test)]
mod tests {
use super::ensure_writable;
use std::fs;
use tempfile::tempdir;

#[test]
fn refuses_existing_file_without_force() {
let dir = tempdir().unwrap();
let path = dir.path().join("sig.auths.json");
fs::write(&path, b"existing").unwrap();

let err = ensure_writable(&path, false).unwrap_err();
assert!(err.to_string().contains("refusing to overwrite"));
}

#[test]
fn allows_existing_file_with_force() {
let dir = tempdir().unwrap();
let path = dir.path().join("sig.auths.json");
fs::write(&path, b"existing").unwrap();

assert!(ensure_writable(&path, true).is_ok());
}

#[test]
fn allows_new_path_without_force() {
let dir = tempdir().unwrap();
let path = dir.path().join("new.auths.json");

assert!(ensure_writable(&path, false).is_ok());
}
}
Loading
Loading