diff --git a/Cargo.lock b/Cargo.lock index ac611358..f09b79a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,6 +327,7 @@ dependencies = [ "axum", "base64", "bs58", + "capsec", "chrono", "clap", "clap_complete", @@ -511,6 +512,7 @@ dependencies = [ "auths-sdk", "auths-test-utils", "auths-verifier", + "capsec", "chrono", "git2", "log", @@ -526,6 +528,7 @@ dependencies = [ "auths-core", "auths-verifier", "axum", + "capsec", "chrono", "futures-util", "hex", @@ -656,7 +659,7 @@ dependencies = [ "git2", "json-canon", "radicle-core", - "radicle-crypto", + "radicle-crypto 0.14.0", "ring", "serde", "serde_json", @@ -760,6 +763,7 @@ name = "auths-test-utils" version = "0.0.1-rc.13" dependencies = [ "auths-crypto", + "capsec", "git2", "ring", "tempfile", @@ -1571,6 +1575,57 @@ dependencies = [ "either", ] +[[package]] +name = "capsec" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f46a167f8abcb16872b6ab918a53a565fa09f86e58d2269ef392e62e729dd4" +dependencies = [ + "capsec-core", + "capsec-macro", + "capsec-std", + "capsec-tokio", +] + +[[package]] +name = "capsec-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b849841dec32ff1bcd22ad5828ef69c501f0c2ef7a14d1a0a79ec7e6b78b396" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "capsec-macro" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc06cc5ccec28200116079e0304c5706306c47893292e3fc3cb303437c9c2f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "capsec-std" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c2e28daa003c97c70d2cc0e28bf59c945a2be739dc59989dbc585de012b915" +dependencies = [ + "capsec-core", +] + +[[package]] +name = "capsec-tokio" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db60f2882b53675e73a78b68f2062c0548c50432254d12e01a419b206705506" +dependencies = [ + "capsec-core", + "tokio", +] + [[package]] name = "cast" version = "0.3.0" @@ -1800,7 +1855,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4907,7 +4962,7 @@ name = "radicle-core" version = "0.1.0" dependencies = [ "multibase", - "radicle-crypto", + "radicle-crypto 0.15.0", "radicle-oid", "schemars 1.2.1", "serde", @@ -4917,6 +4972,20 @@ dependencies = [ [[package]] name = "radicle-crypto" version = "0.14.0" +source = "git+https://github.com/bordumb/heartwood?branch=dev-authsIntegration-1.6.1#5e9bd04e830d2a7227e6133832b7ef9c4385492d" +dependencies = [ + "amplify", + "ec25519", + "multibase", + "serde", + "signature 2.2.0", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "radicle-crypto" +version = "0.15.0" dependencies = [ "amplify", "ec25519", @@ -7280,7 +7349,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2eed8dfd..abbb5853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,8 @@ subtle = "2.6" zeroize = { version = "1.8.1", features = ["serde", "derive"] } # Exact pin: canonicalization changes silently break all existing attestation signatures. json-canon = "=0.1.3" +# Exact pin: pre-1.0 capability API — pin to avoid silent breaking changes. +capsec = { version = "=0.1.6", features = ["tokio"] } auths-core = { path = "crates/auths-core", version = "0.0.1-rc.4" } auths-id = { path = "crates/auths-id", version = "0.0.1-rc.4" } diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index 585ae73b..1f8d3e9c 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -72,6 +72,7 @@ url = "2.5" which = "8.0.0" open = "5" indicatif = "0.18.4" +capsec.workspace = true # LAN pairing (optional, default-on) auths-pairing-daemon = { workspace = true, optional = true } diff --git a/crates/auths-cli/src/adapters/config_store.rs b/crates/auths-cli/src/adapters/config_store.rs index 789c52c2..c9f9f741 100644 --- a/crates/auths-cli/src/adapters/config_store.rs +++ b/crates/auths-cli/src/adapters/config_store.rs @@ -3,32 +3,58 @@ use std::path::Path; use auths_core::ports::config_store::{ConfigStore, ConfigStoreError}; +use capsec::SendCap; /// Reads and writes config files from the local filesystem. -pub struct FileConfigStore; +pub struct FileConfigStore { + _fs_read: SendCap, + fs_write: SendCap, +} + +impl FileConfigStore { + pub fn new(fs_read: SendCap, fs_write: SendCap) -> Self { + Self { + _fs_read: fs_read, + fs_write, + } + } +} impl ConfigStore for FileConfigStore { fn read(&self, path: &Path) -> Result, ConfigStoreError> { - match std::fs::read_to_string(path) { + match capsec::fs::read_to_string(path, &self._fs_read) { Ok(content) => Ok(Some(content)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(capsec::CapSecError::Io(ref io_err)) + if io_err.kind() == std::io::ErrorKind::NotFound => + { + Ok(None) + } Err(e) => Err(ConfigStoreError::Read { path: path.to_path_buf(), - source: e, + source: capsec_to_io(e), }), } } fn write(&self, path: &Path, content: &str) -> Result<(), ConfigStoreError> { if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write { - path: path.to_path_buf(), - source: e, + capsec::fs::create_dir_all(parent, &self.fs_write).map_err(|e| { + ConfigStoreError::Write { + path: path.to_path_buf(), + source: capsec_to_io(e), + } })?; } - std::fs::write(path, content).map_err(|e| ConfigStoreError::Write { + capsec::fs::write(path, content, &self.fs_write).map_err(|e| ConfigStoreError::Write { path: path.to_path_buf(), - source: e, + source: capsec_to_io(e), }) } } + +fn capsec_to_io(e: capsec::CapSecError) -> std::io::Error { + match e { + capsec::CapSecError::Io(io) => io, + other => std::io::Error::other(other.to_string()), + } +} diff --git a/crates/auths-cli/src/adapters/git_config.rs b/crates/auths-cli/src/adapters/git_config.rs index 3c3ced08..6adf4476 100644 --- a/crates/auths-cli/src/adapters/git_config.rs +++ b/crates/auths-cli/src/adapters/git_config.rs @@ -1,3 +1,4 @@ +use capsec::SendCap; use std::path::PathBuf; use auths_sdk::ports::git_config::{GitConfigError, GitConfigProvider}; @@ -5,25 +6,30 @@ use auths_sdk::ports::git_config::{GitConfigError, GitConfigProvider}; /// System adapter for git signing configuration. /// /// Runs `git config ` via `std::process::Command`. -/// Construct with `SystemGitConfigProvider::global()` or -/// `SystemGitConfigProvider::local(repo_path)`. +/// Holds a `SendCap` to document subprocess execution. /// /// Usage: /// ```ignore -/// let provider = SystemGitConfigProvider::global(); +/// let cap_root = capsec::test_root(); +/// let provider = SystemGitConfigProvider::global(cap_root.spawn().make_send()); /// provider.set("gpg.format", "ssh")?; /// ``` pub struct SystemGitConfigProvider { scope_flag: &'static str, working_dir: Option, + _spawn_cap: SendCap, } impl SystemGitConfigProvider { /// Creates a provider that sets git config in global scope. - pub fn global() -> Self { + /// + /// Args: + /// * `spawn_cap`: Capability token proving the caller has subprocess execution permission. + pub fn global(spawn_cap: SendCap) -> Self { Self { scope_flag: "--global", working_dir: None, + _spawn_cap: spawn_cap, } } @@ -31,10 +37,12 @@ impl SystemGitConfigProvider { /// /// Args: /// * `repo_path`: Path to the git repository to configure. - pub fn local(repo_path: PathBuf) -> Self { + /// * `spawn_cap`: Capability token proving the caller has subprocess execution permission. + pub fn local(repo_path: PathBuf, spawn_cap: SendCap) -> Self { Self { scope_flag: "--local", working_dir: Some(repo_path), + _spawn_cap: spawn_cap, } } } diff --git a/crates/auths-cli/src/adapters/local_file.rs b/crates/auths-cli/src/adapters/local_file.rs index 43bc1969..f5bc0479 100644 --- a/crates/auths-cli/src/adapters/local_file.rs +++ b/crates/auths-cli/src/adapters/local_file.rs @@ -1,6 +1,7 @@ //! Local filesystem artifact adapter. use auths_sdk::ports::artifact::{ArtifactDigest, ArtifactError, ArtifactMetadata, ArtifactSource}; +use capsec::SendCap; use sha2::{Digest, Sha256}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -9,16 +10,21 @@ use std::path::{Path, PathBuf}; /// /// Usage: /// ```ignore -/// let artifact = LocalFileArtifact::new("path/to/file.tar.gz"); +/// let cap_root = capsec::test_root(); +/// let artifact = LocalFileArtifact::new("path/to/file.tar.gz", cap_root.fs_read().make_send()); /// let digest = artifact.digest()?; /// ``` pub struct LocalFileArtifact { path: PathBuf, + fs_read: SendCap, } impl LocalFileArtifact { - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } + pub fn new(path: impl Into, fs_read: SendCap) -> Self { + Self { + path: path.into(), + fs_read, + } } pub fn path(&self) -> &Path { @@ -28,7 +34,7 @@ impl LocalFileArtifact { impl ArtifactSource for LocalFileArtifact { fn digest(&self) -> Result { - let mut file = std::fs::File::open(&self.path) + let mut file = capsec::fs::open(&self.path, &self.fs_read) .map_err(|e| ArtifactError::Io(format!("{}: {}", self.path.display(), e)))?; let mut hasher = Sha256::new(); @@ -52,7 +58,7 @@ impl ArtifactSource for LocalFileArtifact { fn metadata(&self) -> Result { let digest = self.digest()?; - let file_meta = std::fs::metadata(&self.path) + let file_meta = capsec::fs::metadata(&self.path, &self.fs_read) .map_err(|e| ArtifactError::Metadata(format!("{}: {}", self.path.display(), e)))?; Ok(ArtifactMetadata { diff --git a/crates/auths-cli/src/bin/sign.rs b/crates/auths-cli/src/bin/sign.rs index 0c784597..6f0aac03 100644 --- a/crates/auths-cli/src/bin/sign.rs +++ b/crates/auths-cli/src/bin/sign.rs @@ -39,6 +39,7 @@ use auths_core::storage::passphrase_cache::{get_passphrase_cache, parse_duration use auths_sdk::workflows::signing::{ CommitSigningContext, CommitSigningParams, CommitSigningWorkflow, }; +use capsec::SendCap; /// Auths SSH signing program for Git integration. /// @@ -125,7 +126,11 @@ fn parse_key_identifier(key_file: &str) -> Result { } } -fn build_signing_context(alias: &str) -> Result { +fn build_signing_context( + alias: &str, + fs_read: SendCap, + fs_write: SendCap, +) -> Result { let env_config = EnvironmentConfig::from_env(); let keychain = @@ -135,7 +140,8 @@ fn build_signing_context(alias: &str) -> Result { if let Some(passphrase) = env_config.keychain.passphrase.clone() { Arc::new(auths_core::PrefilledPassphraseProvider::new(&passphrase)) } else { - let config = load_config(&FileConfigStore); + let store = FileConfigStore::new(fs_read, fs_write); + let config = load_config(&store); let cache = get_passphrase_cache(config.passphrase.biometric); let ttl_secs = config .passphrase @@ -260,6 +266,8 @@ fn run_delegate_to_ssh_keygen(args: &Args) -> Result<()> { } fn run_sign(args: &Args) -> Result<()> { + let cap_root = capsec::root(); + let file_arg = args .file_arg .as_deref() @@ -279,7 +287,11 @@ fn run_sign(args: &Args) -> Result<()> { let repo_path = auths_id::storage::layout::resolve_repo_path(None).ok(); - let ctx = build_signing_context(&alias)?; + let ctx = build_signing_context( + &alias, + cap_root.fs_read().make_send(), + cap_root.fs_write().make_send(), + )?; let mut params = CommitSigningParams::new(&alias, namespace, data).with_pubkey(pubkey); if let Some(path) = repo_path { params = params.with_repo_path(path); diff --git a/crates/auths-cli/src/commands/artifact/file.rs b/crates/auths-cli/src/commands/artifact/file.rs index ba049810..0a896049 100644 --- a/crates/auths-cli/src/commands/artifact/file.rs +++ b/crates/auths-cli/src/commands/artifact/file.rs @@ -15,11 +15,12 @@ mod tests { #[test] fn file_artifact_digest_is_deterministic() { + let cap_root = capsec::test_root(); let mut tmp = NamedTempFile::new().unwrap(); tmp.write_all(b"hello world").unwrap(); tmp.flush().unwrap(); - let a = FileArtifact::new(tmp.path()); + let a = FileArtifact::new(tmp.path(), cap_root.fs_read().make_send()); let d1 = a.digest().unwrap(); let d2 = a.digest().unwrap(); @@ -33,11 +34,12 @@ mod tests { #[test] fn file_artifact_metadata_includes_name_and_size() { + let cap_root = capsec::test_root(); let mut tmp = NamedTempFile::new().unwrap(); tmp.write_all(b"some content").unwrap(); tmp.flush().unwrap(); - let a = FileArtifact::new(tmp.path()); + let a = FileArtifact::new(tmp.path(), cap_root.fs_read().make_send()); let meta = a.metadata().unwrap(); assert_eq!(meta.artifact_type, "file"); @@ -47,7 +49,11 @@ mod tests { #[test] fn file_artifact_nonexistent_returns_error() { - let a = FileArtifact::new(Path::new("/nonexistent/path/to/file.txt")); + let cap_root = capsec::test_root(); + let a = FileArtifact::new( + Path::new("/nonexistent/path/to/file.txt"), + cap_root.fs_read().make_send(), + ); assert!(a.digest().is_err()); } } diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index 669be6f5..793ec1e8 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -12,6 +12,8 @@ use anyhow::Result; use auths_core::config::EnvironmentConfig; use auths_core::signing::PassphraseProvider; +use crate::config::Capabilities; + #[derive(Args, Debug, Clone)] #[command(about = "Sign and verify arbitrary artifacts (tarballs, binaries, etc.).")] pub struct ArtifactCommand { @@ -106,6 +108,7 @@ pub fn handle_artifact( repo_opt: Option, passphrase_provider: Arc, env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Result<()> { match cmd.command { ArtifactSubcommand::Sign { @@ -133,13 +136,14 @@ pub fn handle_artifact( repo_opt, passphrase_provider, env_config, + caps, ) } ArtifactSubcommand::Publish { signature, package, registry, - } => publish::handle_publish(&signature, package.as_deref(), ®istry), + } => publish::handle_publish(&signature, package.as_deref(), ®istry, caps), ArtifactSubcommand::Verify { file, signature, @@ -156,6 +160,7 @@ pub fn handle_artifact( witness_receipts, &witness_keys, witness_threshold, + caps, )) } } @@ -168,6 +173,7 @@ impl crate::commands::executable::ExecutableCommand for ArtifactCommand { ctx.repo_path.clone(), ctx.passphrase_provider.clone(), &ctx.env_config, + &ctx.caps, ) } } diff --git a/crates/auths-cli/src/commands/artifact/publish.rs b/crates/auths-cli/src/commands/artifact/publish.rs index 98e97f87..21100ae5 100644 --- a/crates/auths-cli/src/commands/artifact/publish.rs +++ b/crates/auths-cli/src/commands/artifact/publish.rs @@ -10,6 +10,7 @@ use auths_transparency::OfflineBundle; use auths_verifier::core::ResourceId; use serde::Serialize; +use crate::config::Capabilities; use crate::ux::format::{JsonResponse, Output, is_json_mode}; #[derive(Serialize)] @@ -31,9 +32,19 @@ struct PublishJsonResponse { /// ```ignore /// handle_publish(Path::new("artifact.auths.json"), Some("npm:react@18.3.0"), "https://public.auths.dev")?; /// ``` -pub fn handle_publish(signature_path: &Path, package: Option<&str>, registry: &str) -> Result<()> { +pub fn handle_publish( + signature_path: &Path, + package: Option<&str>, + registry: &str, + caps: &Capabilities, +) -> Result<()> { let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; - rt.block_on(handle_publish_async(signature_path, package, registry)) + rt.block_on(handle_publish_async( + signature_path, + package, + registry, + caps, + )) } fn validate_package_identifier(package: &str) -> Result { @@ -60,6 +71,7 @@ async fn handle_publish_async( signature_path: &Path, package: Option<&str>, registry: &str, + caps: &Capabilities, ) -> Result<()> { if !signature_path.exists() { bail!( @@ -99,8 +111,11 @@ async fn handle_publish_async( }; let registry_url = registry.trim_end_matches('/').to_string(); - let registry_client = - HttpRegistryClient::new_with_timeouts(Duration::from_secs(30), Duration::from_secs(60)); + let registry_client = HttpRegistryClient::new_with_timeouts( + Duration::from_secs(30), + Duration::from_secs(60), + caps.net_connect.clone(), + ); let config = ArtifactPublishConfig { attestation, package_name, diff --git a/crates/auths-cli/src/commands/artifact/sign.rs b/crates/auths-cli/src/commands/artifact/sign.rs index 2e1a07b7..dc7539f0 100644 --- a/crates/auths-cli/src/commands/artifact/sign.rs +++ b/crates/auths-cli/src/commands/artifact/sign.rs @@ -8,6 +8,7 @@ use auths_core::storage::keychain::KeyAlias; use auths_sdk::signing::{ArtifactSigningParams, SigningKeyMaterial, sign_artifact}; use super::file::FileArtifact; +use crate::config::Capabilities; use crate::factories::storage::build_auths_context; /// Execute the `artifact sign` command. @@ -22,13 +23,14 @@ pub fn handle_sign( repo_opt: Option, passphrase_provider: Arc, env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Result<()> { let repo_path = auths_id::storage::layout::resolve_repo_path(repo_opt)?; - let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?; + let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider), caps)?; let params = ArtifactSigningParams { - artifact: Arc::new(FileArtifact::new(file)), + artifact: Arc::new(FileArtifact::new(file, caps.fs_read.clone())), identity_key: identity_key_alias .map(|a| SigningKeyMaterial::Alias(KeyAlias::new_unchecked(a))), device_key: SigningKeyMaterial::Alias(KeyAlias::new_unchecked(device_key_alias)), diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index 29e568ad..c009b92c 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -17,6 +17,7 @@ use auths_verifier::{ use super::core::{ArtifactMetadata, ArtifactSource}; use super::file::FileArtifact; use crate::commands::verify_helpers::parse_witness_keys; +use crate::config::Capabilities; use crate::ux::format::is_json_mode; /// JSON output for `artifact verify --json`. @@ -50,6 +51,7 @@ pub async fn handle_verify( witness_receipts: Option, witness_keys: &[String], witness_threshold: usize, + caps: &Capabilities, ) -> Result<()> { let file_str = file.to_string_lossy().to_string(); @@ -115,7 +117,7 @@ pub async fn handle_verify( }; // 4. Compute file digest and compare - let file_artifact = FileArtifact::new(file); + let file_artifact = FileArtifact::new(file, caps.fs_read.clone()); let file_digest = match file_artifact.digest() { Ok(d) => d, Err(e) => { diff --git a/crates/auths-cli/src/commands/config.rs b/crates/auths-cli/src/commands/config.rs index e73b035b..ee1e3f52 100644 --- a/crates/auths-cli/src/commands/config.rs +++ b/crates/auths-cli/src/commands/config.rs @@ -36,17 +36,17 @@ pub enum ConfigAction { } impl ExecutableCommand for ConfigCommand { - fn execute(&self, _ctx: &CliConfig) -> Result<()> { + fn execute(&self, ctx: &CliConfig) -> Result<()> { match &self.action { - ConfigAction::Set { key, value } => execute_set(key, value), - ConfigAction::Get { key } => execute_get(key), - ConfigAction::Show => execute_show(), + ConfigAction::Set { key, value } => execute_set(key, value, ctx), + ConfigAction::Get { key } => execute_get(key, ctx), + ConfigAction::Show => execute_show(ctx), } } } -fn execute_set(key: &str, value: &str) -> Result<()> { - let store = FileConfigStore; +fn execute_set(key: &str, value: &str, ctx: &CliConfig) -> Result<()> { + let store = FileConfigStore::new(ctx.caps.fs_read.clone(), ctx.caps.fs_write.clone()); let mut config = load_config(&store); match key { @@ -76,8 +76,9 @@ fn execute_set(key: &str, value: &str) -> Result<()> { Ok(()) } -fn execute_get(key: &str) -> Result<()> { - let config = load_config(&FileConfigStore); +fn execute_get(key: &str, ctx: &CliConfig) -> Result<()> { + let store = FileConfigStore::new(ctx.caps.fs_read.clone(), ctx.caps.fs_write.clone()); + let config = load_config(&store); match key { "passphrase.cache" => { @@ -102,8 +103,9 @@ fn execute_get(key: &str) -> Result<()> { Ok(()) } -fn execute_show() -> Result<()> { - let config = load_config(&FileConfigStore); +fn execute_show(ctx: &CliConfig) -> Result<()> { + let store = FileConfigStore::new(ctx.caps.fs_read.clone(), ctx.caps.fs_write.clone()); + let config = load_config(&store); let toml_str = toml::to_string_pretty(&config) .map_err(|e| anyhow::anyhow!("Failed to serialize config: {}", e))?; println!("{}", toml_str); @@ -140,8 +142,8 @@ fn parse_bool(s: &str) -> Result { } } -fn _ensure_default_config_exists() -> Result { - let store = FileConfigStore; +fn _ensure_default_config_exists(ctx: &CliConfig) -> Result { + let store = FileConfigStore::new(ctx.caps.fs_read.clone(), ctx.caps.fs_write.clone()); let config = load_config(&store); save_config(&config, &store)?; Ok(config) diff --git a/crates/auths-cli/src/commands/device/authorization.rs b/crates/auths-cli/src/commands/device/authorization.rs index 75bb15e4..75fb6848 100644 --- a/crates/auths-cli/src/commands/device/authorization.rs +++ b/crates/auths-cli/src/commands/device/authorization.rs @@ -21,6 +21,7 @@ use auths_storage::git::{ use chrono::Utc; use crate::commands::registry_overrides::RegistryOverrides; +use crate::config::Capabilities; use crate::factories::storage::build_auths_context; use crate::ux::format::{JsonResponse, is_json_mode}; @@ -201,6 +202,7 @@ pub fn handle_device( attestation_blob_name_override: Option, passphrase_provider: Arc, env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Result<()> { #[allow(clippy::disallowed_methods)] let now = Utc::now(); @@ -225,7 +227,7 @@ pub fn handle_device( list_devices(now, &repo_path, &config, include_revoked) } DeviceSubcommand::Resolve { device_did } => resolve_device(&repo_path, &device_did), - DeviceSubcommand::Pair(pair_cmd) => super::pair::handle_pair(pair_cmd, env_config), + DeviceSubcommand::Pair(pair_cmd) => super::pair::handle_pair(pair_cmd, env_config, caps), DeviceSubcommand::VerifyAttestation(verify_cmd) => { let rt = tokio::runtime::Runtime::new()?; rt.block_on(super::verify_attestation::handle_verify(verify_cmd)) @@ -243,7 +245,7 @@ pub fn handle_device( let payload = read_payload_file(payload_path_opt.as_deref())?; validate_payload_schema(schema_path_opt.as_deref(), &payload)?; - let caps: Vec = capabilities + let parsed_capabilities: Vec = capabilities .unwrap_or_default() .into_iter() .filter_map(|s| auths_verifier::Capability::parse(&s).ok()) @@ -253,7 +255,7 @@ pub fn handle_device( identity_key_alias: KeyAlias::new_unchecked(identity_key_alias), device_key_alias: Some(KeyAlias::new_unchecked(device_key_alias)), device_did: Some(device_did.clone()), - capabilities: caps, + capabilities: parsed_capabilities, expires_in, note, payload, @@ -265,6 +267,7 @@ pub fn handle_device( &repo_path, env_config, Some(Arc::clone(&passphrase_provider)), + caps, )?; let result = auths_sdk::device::link_device( @@ -291,6 +294,7 @@ pub fn handle_device( &repo_path, env_config, Some(Arc::clone(&passphrase_provider)), + caps, )?; let identity_key_alias = KeyAlias::new_unchecked(identity_key_alias); @@ -320,6 +324,7 @@ pub fn handle_device( &device_key_alias, passphrase_provider, env_config, + caps, ), } } @@ -432,6 +437,7 @@ fn handle_extend( device_key_alias: &str, passphrase_provider: Arc, env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Result<()> { let config = auths_sdk::types::DeviceExtensionConfig { repo_path: repo_path.to_path_buf(), @@ -441,7 +447,7 @@ fn handle_extend( identity_key_alias: KeyAlias::new_unchecked(identity_key_alias), device_key_alias: Some(KeyAlias::new_unchecked(device_key_alias)), }; - let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider))?; + let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider), caps)?; let result = auths_sdk::device::extend_device(config, &ctx, &auths_core::ports::clock::SystemClock) diff --git a/crates/auths-cli/src/commands/device/mod.rs b/crates/auths-cli/src/commands/device/mod.rs index 49915ae5..0e329b7c 100644 --- a/crates/auths-cli/src/commands/device/mod.rs +++ b/crates/auths-cli/src/commands/device/mod.rs @@ -12,7 +12,7 @@ use anyhow::Result; impl ExecutableCommand for PairCommand { fn execute(&self, ctx: &CliConfig) -> Result<()> { - handle_pair(self.clone(), &ctx.env_config) + handle_pair(self.clone(), &ctx.env_config, &ctx.caps) } } @@ -27,6 +27,7 @@ impl ExecutableCommand for DeviceCommand { self.overrides.attestation_blob.clone(), ctx.passphrase_provider.clone(), &ctx.env_config, + &ctx.caps, ) } } diff --git a/crates/auths-cli/src/commands/device/pair/join.rs b/crates/auths-cli/src/commands/device/pair/join.rs index 0e0ca9b4..d7b0eb7a 100644 --- a/crates/auths-cli/src/commands/device/pair/join.rs +++ b/crates/auths-cli/src/commands/device/pair/join.rs @@ -10,6 +10,7 @@ use auths_pairing_protocol::sas; use auths_sdk::pairing::{load_device_signing_material, validate_short_code}; use console::style; +use crate::config::Capabilities; use crate::core::provider::CliPassphraseProvider; use crate::factories::storage::build_auths_context; @@ -21,6 +22,7 @@ pub(crate) async fn handle_join( code: &str, registry: &str, env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Result<()> { let normalized = validate_short_code(code).map_err(|e| anyhow::anyhow!("{}", e))?; @@ -54,7 +56,7 @@ pub(crate) async fn handle_join( let key_spinner = create_wait_spinner(&format!("{GEAR}Loading local device key...")); - let ctx = build_auths_context(&auths_dir, env_config, Some(passphrase_provider)) + let ctx = build_auths_context(&auths_dir, env_config, Some(passphrase_provider), caps) .context("Failed to build auths context")?; let material = load_device_signing_material(&ctx).map_err(|e| anyhow::anyhow!("{}", e))?; diff --git a/crates/auths-cli/src/commands/device/pair/lan.rs b/crates/auths-cli/src/commands/device/pair/lan.rs index dacae6d3..b03ef25c 100644 --- a/crates/auths-cli/src/commands/device/pair/lan.rs +++ b/crates/auths-cli/src/commands/device/pair/lan.rs @@ -218,6 +218,7 @@ pub async fn handle_join_lan( now: chrono::DateTime, code: &str, env_config: &EnvironmentConfig, + caps: &crate::config::Capabilities, ) -> Result<()> { use auths_core::pairing::normalize_short_code; @@ -260,5 +261,5 @@ pub async fn handle_join_lan( let registry = format!("http://{}", addr); // Delegate to the standard join flow - super::join::handle_join(now, &normalized, ®istry, env_config).await + super::join::handle_join(now, &normalized, ®istry, env_config, caps).await } diff --git a/crates/auths-cli/src/commands/device/pair/mod.rs b/crates/auths-cli/src/commands/device/pair/mod.rs index 9410e75d..7cecc201 100644 --- a/crates/auths-cli/src/commands/device/pair/mod.rs +++ b/crates/auths-cli/src/commands/device/pair/mod.rs @@ -17,6 +17,8 @@ use auths_core::config::EnvironmentConfig; use chrono::Utc; use clap::Parser; +use crate::config::Capabilities; + /// Default registry URL for local development. #[cfg(not(feature = "lan-pairing"))] const DEFAULT_REGISTRY: &str = "http://localhost:3000"; @@ -73,7 +75,11 @@ pub struct PairCommand { /// | `pair --join CODE` | LAN join: mDNS discover -> join | /// | `pair --join CODE --registry`| Online join (existing) | /// | `pair --offline` | Offline mode (no network) | -pub fn handle_pair(cmd: PairCommand, env_config: &EnvironmentConfig) -> Result<()> { +pub fn handle_pair( + cmd: PairCommand, + env_config: &EnvironmentConfig, + caps: &Capabilities, +) -> Result<()> { #[allow(clippy::disallowed_methods)] let now = Utc::now(); match (&cmd.join, &cmd.registry, cmd.offline) { @@ -85,21 +91,27 @@ pub fn handle_pair(cmd: PairCommand, env_config: &EnvironmentConfig) -> Result<( // Join with explicit registry -> online join (Some(code), Some(registry), _) => { let rt = tokio::runtime::Runtime::new()?; - rt.block_on(join::handle_join(now, code, registry, env_config)) + rt.block_on(join::handle_join(now, code, registry, env_config, caps)) } // Join without registry -> LAN join via mDNS #[cfg(feature = "lan-pairing")] (Some(code), None, _) => { let rt = tokio::runtime::Runtime::new()?; - rt.block_on(lan::handle_join_lan(now, code, env_config)) + rt.block_on(lan::handle_join_lan(now, code, env_config, caps)) } // Join without registry and no LAN feature -> use default registry #[cfg(not(feature = "lan-pairing"))] (Some(code), None, _) => { let rt = tokio::runtime::Runtime::new()?; - rt.block_on(join::handle_join(now, code, DEFAULT_REGISTRY, env_config)) + rt.block_on(join::handle_join( + now, + code, + DEFAULT_REGISTRY, + env_config, + caps, + )) } // Initiate with explicit registry -> online mode @@ -112,6 +124,7 @@ pub fn handle_pair(cmd: PairCommand, env_config: &EnvironmentConfig) -> Result<( cmd.timeout, &cmd.capabilities, env_config, + caps, )) } @@ -140,6 +153,7 @@ pub fn handle_pair(cmd: PairCommand, env_config: &EnvironmentConfig) -> Result<( cmd.timeout, &cmd.capabilities, env_config, + caps, )) } } diff --git a/crates/auths-cli/src/commands/device/pair/online.rs b/crates/auths-cli/src/commands/device/pair/online.rs index af6e93bd..e22bc148 100644 --- a/crates/auths-cli/src/commands/device/pair/online.rs +++ b/crates/auths-cli/src/commands/device/pair/online.rs @@ -9,6 +9,7 @@ use indicatif::ProgressBar; use auths_infra_http::HttpPairingRelayClient; +use crate::config::Capabilities; use crate::core::provider::CliPassphraseProvider; use crate::factories::storage::build_auths_context; @@ -22,6 +23,7 @@ pub(crate) async fn handle_initiate_online( expiry_secs: u64, capabilities: &[String], env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Result<()> { let auths_dir = auths_core::paths::auths_home_with_config(env_config).unwrap_or_default(); @@ -35,7 +37,7 @@ pub(crate) async fn handle_initiate_online( dyn auths_core::signing::PassphraseProvider + Send + Sync, > = std::sync::Arc::new(CliPassphraseProvider::new()); - let ctx = build_auths_context(&auths_dir, env_config, Some(passphrase_provider)) + let ctx = build_auths_context(&auths_dir, env_config, Some(passphrase_provider), caps) .context("Failed to build auths context")?; let relay = HttpPairingRelayClient::new(); diff --git a/crates/auths-cli/src/commands/id/claim.rs b/crates/auths-cli/src/commands/id/claim.rs index 025e17ca..94ac1efa 100644 --- a/crates/auths-cli/src/commands/id/claim.rs +++ b/crates/auths-cli/src/commands/id/claim.rs @@ -9,6 +9,7 @@ use auths_sdk::workflows::platform::{GitHubClaimConfig, claim_github_identity}; use clap::{Parser, Subcommand}; use console::style; +use crate::config::Capabilities; use crate::factories::storage::build_auths_context; use crate::ux::format::{JsonResponse, is_json_mode}; @@ -44,12 +45,13 @@ pub fn handle_claim( passphrase_provider: Arc, env_config: &EnvironmentConfig, now: chrono::DateTime, + caps: &Capabilities, ) -> Result<()> { let registry_url = match &cmd.platform { ClaimPlatform::Github { registry } => registry.clone(), }; - let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider)) + let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider), caps) .context("Failed to build auths context")?; let oauth = HttpGitHubOAuthProvider::new(); diff --git a/crates/auths-cli/src/commands/id/identity.rs b/crates/auths-cli/src/commands/id/identity.rs index aa90abd7..5ff03c63 100644 --- a/crates/auths-cli/src/commands/id/identity.rs +++ b/crates/auths-cli/src/commands/id/identity.rs @@ -16,6 +16,7 @@ use auths_verifier::{IdentityBundle, IdentityDID, Prefix}; use clap::ValueEnum; use crate::commands::registry_overrides::RegistryOverrides; +use crate::config::Capabilities; use crate::ux::format::{JsonResponse, is_json_mode}; /// JSON response for id show command. @@ -255,6 +256,7 @@ pub fn handle_id( passphrase_provider: Arc, env_config: &EnvironmentConfig, now: chrono::DateTime, + caps: &Capabilities, ) -> Result<()> { // Determine repo path using the passed Option let repo_path = layout::resolve_repo_path(repo_opt)?; @@ -310,7 +312,7 @@ pub fn handle_id( let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone()); if repo_path.exists() { - match open_git_repo(&repo_path) { + match open_git_repo(&repo_path, caps) { Ok(_repo) => { println!(" Git repository found at {:?}.", repo_path); if identity_storage_check.load_identity().is_ok() { @@ -331,7 +333,7 @@ pub fn handle_id( " Path {:?} exists but is not a Git repository. Initializing...", repo_path ); - ensure_git_repo(&repo_path).map_err(|e| { + ensure_git_repo(&repo_path, caps).map_err(|e| { anyhow!( "Path {:?} exists but failed to initialize as Git repository: {}", repo_path, @@ -343,7 +345,7 @@ pub fn handle_id( } } else { println!(" Initializing Git repository at {:?}...", repo_path); - ensure_git_repo(&repo_path).map_err(|e| { + ensure_git_repo(&repo_path, caps).map_err(|e| { anyhow!( "Failed to initialize Git repository at {:?}: {}", repo_path, @@ -639,12 +641,17 @@ pub fn handle_id( } IdSubcommand::Register { registry } => { - super::register::handle_register(&repo_path, ®istry) + super::register::handle_register(&repo_path, ®istry, caps) } - IdSubcommand::Claim(claim_cmd) => { - super::claim::handle_claim(&claim_cmd, &repo_path, passphrase_provider, env_config, now) - } + IdSubcommand::Claim(claim_cmd) => super::claim::handle_claim( + &claim_cmd, + &repo_path, + passphrase_provider, + env_config, + now, + caps, + ), IdSubcommand::Migrate(migrate_cmd) => super::migrate::handle_migrate(migrate_cmd, now), diff --git a/crates/auths-cli/src/commands/id/mod.rs b/crates/auths-cli/src/commands/id/mod.rs index 44d248cd..a5bfda2c 100644 --- a/crates/auths-cli/src/commands/id/mod.rs +++ b/crates/auths-cli/src/commands/id/mod.rs @@ -25,6 +25,7 @@ impl ExecutableCommand for IdCommand { ctx.passphrase_provider.clone(), &ctx.env_config, chrono::Utc::now(), + &ctx.caps, ) } } diff --git a/crates/auths-cli/src/commands/id/register.rs b/crates/auths-cli/src/commands/id/register.rs index 44a9232e..14040502 100644 --- a/crates/auths-cli/src/commands/id/register.rs +++ b/crates/auths-cli/src/commands/id/register.rs @@ -16,6 +16,7 @@ use auths_storage::git::{ GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage, }; +use crate::config::Capabilities; use crate::ux::format::{JsonResponse, Output, is_json_mode}; #[derive(Serialize)] @@ -35,7 +36,7 @@ struct RegisterJsonResponse { /// ```ignore /// handle_register(&repo_path, "https://public.auths.dev")?; /// ``` -pub fn handle_register(repo_path: &Path, registry: &str) -> Result<()> { +pub fn handle_register(repo_path: &Path, registry: &str, caps: &Capabilities) -> Result<()> { let rt = tokio::runtime::Runtime::new()?; let backend: Arc = Arc::new( @@ -46,7 +47,7 @@ pub fn handle_register(repo_path: &Path, registry: &str) -> Result<()> { let attestation_store = Arc::new(RegistryAttestationStorage::new(repo_path)); let attestation_source: Arc = attestation_store; - let registry_client = HttpRegistryClient::new(); + let registry_client = HttpRegistryClient::new(caps.net_connect.clone()); match rt.block_on(auths_sdk::registration::register_identity( identity_storage, diff --git a/crates/auths-cli/src/commands/init/gather.rs b/crates/auths-cli/src/commands/init/gather.rs index cb65e678..f6477adb 100644 --- a/crates/auths-cli/src/commands/init/gather.rs +++ b/crates/auths-cli/src/commands/init/gather.rs @@ -18,6 +18,7 @@ use super::helpers::{ check_git_version, detect_ci_environment, get_auths_repo_path, select_agent_capabilities, }; use super::prompts::{prompt_for_alias, prompt_for_conflict_policy, prompt_for_git_scope}; +use crate::config::Capabilities; use crate::ux::format::Output; pub(crate) fn gather_developer_config( @@ -130,6 +131,7 @@ pub(crate) fn submit_registration( proof_url: Option, skip: bool, out: &Output, + caps: &Capabilities, ) -> Option { if skip { out.print_info("Registration skipped (--skip-registration)"); @@ -153,7 +155,7 @@ pub(crate) fn submit_registration( let attestation_store = Arc::new(RegistryAttestationStorage::new(repo_path)); let attestation_source: Arc = attestation_store; - let registry_client = HttpRegistryClient::new(); + let registry_client = HttpRegistryClient::new(caps.net_connect.clone()); match rt.block_on(auths_sdk::registration::register_identity( identity_storage, diff --git a/crates/auths-cli/src/commands/init/mod.rs b/crates/auths-cli/src/commands/init/mod.rs index 3b74c635..18934b08 100644 --- a/crates/auths-cli/src/commands/init/mod.rs +++ b/crates/auths-cli/src/commands/init/mod.rs @@ -188,15 +188,18 @@ fn run_developer_setup( } let git_config_provider: Option> = match &config.git_signing_scope { GitSigningScope::Skip => None, - GitSigningScope::Global => Some(Box::new(SystemGitConfigProvider::global())), - GitSigningScope::Local { repo_path } => { - Some(Box::new(SystemGitConfigProvider::local(repo_path.clone()))) - } + GitSigningScope::Global => Some(Box::new(SystemGitConfigProvider::global( + ctx.caps.spawn.clone(), + ))), + GitSigningScope::Local { repo_path } => Some(Box::new(SystemGitConfigProvider::local( + repo_path.clone(), + ctx.caps.spawn.clone(), + ))), }; // EXECUTE guide.section("Creating Identity"); - let sdk_ctx = build_auths_context(®istry_path, &ctx.env_config, None)?; + let sdk_ctx = build_auths_context(®istry_path, &ctx.env_config, None, &ctx.caps)?; let keychain_arc: Arc = Arc::from(keychain); let signer = StorageSigner::new(Arc::clone(&keychain_arc)); let result = initialize( @@ -225,6 +228,7 @@ fn run_developer_setup( Arc::clone(&ctx.passphrase_provider), &ctx.env_config, now, + &ctx.caps, )? { Some((url, _username)) => { out.print_success(&format!("Proof anchored: {}", url)); @@ -252,6 +256,7 @@ fn run_developer_setup( proof_url, cmd.skip_registration, out, + &ctx.caps, ); display_developer_result(out, &result, registered.as_deref()); @@ -269,7 +274,7 @@ fn run_ci_setup(out: &Output, ctx: &CliConfig) -> Result<()> { // EXECUTE guide.section("Creating CI Identity"); - let sdk_ctx = build_auths_context(®istry_path, &ctx.env_config, None)?; + let sdk_ctx = build_auths_context(®istry_path, &ctx.env_config, None, &ctx.caps)?; let keychain_arc: Arc = Arc::from(keychain); let signer = StorageSigner::new(Arc::clone(&keychain_arc)); let provider = PrefilledPassphraseProvider::new(&passphrase_str); @@ -314,7 +319,7 @@ fn run_agent_setup( // EXECUTE guide.section("Creating Agent Identity"); ensure_registry_dir(®istry_path)?; - let sdk_ctx = build_auths_context(®istry_path, &ctx.env_config, None)?; + let sdk_ctx = build_auths_context(®istry_path, &ctx.env_config, None, &ctx.caps)?; let keychain_arc: Arc = Arc::from(keychain); let signer = StorageSigner::new(Arc::clone(&keychain_arc)); let result = initialize( diff --git a/crates/auths-cli/src/commands/init/prompts.rs b/crates/auths-cli/src/commands/init/prompts.rs index 8d8514e4..8eecdf56 100644 --- a/crates/auths-cli/src/commands/init/prompts.rs +++ b/crates/auths-cli/src/commands/init/prompts.rs @@ -14,6 +14,7 @@ use auths_storage::git::RegistryIdentityStorage; use super::InitCommand; use super::InitProfile; use super::helpers::get_auths_repo_path; +use crate::config::Capabilities; use crate::factories::storage::build_auths_context; use crate::ux::format::Output; @@ -119,6 +120,7 @@ pub(crate) fn prompt_platform_verification( passphrase_provider: Arc, env_config: &auths_core::config::EnvironmentConfig, now: chrono::DateTime, + caps: &Capabilities, ) -> Result> { let items = [ "GitHub — link your GitHub identity (recommended)", @@ -133,7 +135,7 @@ pub(crate) fn prompt_platform_verification( .interact()?; match selection { - 0 => run_github_verification(out, passphrase_provider, env_config, now), + 0 => run_github_verification(out, passphrase_provider, env_config, now, caps), 1 => { out.print_warn("GitLab integration is coming soon. Continuing as anonymous."); Ok(None) @@ -147,6 +149,7 @@ fn run_github_verification( passphrase_provider: Arc, env_config: &auths_core::config::EnvironmentConfig, now: chrono::DateTime, + caps: &Capabilities, ) -> Result> { use std::time::Duration; @@ -161,7 +164,7 @@ fn run_github_verification( std::env::var("AUTHS_GITHUB_CLIENT_ID").unwrap_or_else(|_| GITHUB_CLIENT_ID.to_string()); let auths_dir = get_auths_repo_path()?; - let ctx = build_auths_context(&auths_dir, env_config, Some(passphrase_provider))?; + let ctx = build_auths_context(&auths_dir, env_config, Some(passphrase_provider), caps)?; let oauth = HttpGitHubOAuthProvider::new(); let publisher = HttpGistPublisher::new(); diff --git a/crates/auths-cli/src/commands/log.rs b/crates/auths-cli/src/commands/log.rs index affa7d24..c5a7cb34 100644 --- a/crates/auths-cli/src/commands/log.rs +++ b/crates/auths-cli/src/commands/log.rs @@ -8,7 +8,7 @@ use clap::{Args, Subcommand}; use serde::Serialize; use super::executable::ExecutableCommand; -use crate::config::CliConfig; +use crate::config::{Capabilities, CliConfig}; use crate::ux::format::{JsonResponse, is_json_mode}; #[derive(Args, Debug, Clone)] @@ -53,21 +53,24 @@ struct VerifyResult { } impl ExecutableCommand for LogCommand { - fn execute(&self, _ctx: &CliConfig) -> Result<()> { + fn execute(&self, ctx: &CliConfig) -> Result<()> { let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; rt.block_on(async { match &self.command { - LogSubcommand::Inspect(args) => handle_inspect(args).await, - LogSubcommand::Verify(args) => handle_verify(args).await, + LogSubcommand::Inspect(args) => handle_inspect(args, &ctx.caps).await, + LogSubcommand::Verify(args) => handle_verify(args, &ctx.caps).await, } }) } } -async fn handle_inspect(args: &InspectArgs) -> Result<()> { +async fn handle_inspect(args: &InspectArgs, caps: &Capabilities) -> Result<()> { let registry_url = args.registry.trim_end_matches('/'); - let client = - HttpRegistryClient::new_with_timeouts(Duration::from_secs(30), Duration::from_secs(60)); + let client = HttpRegistryClient::new_with_timeouts( + Duration::from_secs(30), + Duration::from_secs(60), + caps.net_connect.clone(), + ); let path = format!("v1/log/entries/{}", args.sequence); let response_bytes = client @@ -120,7 +123,7 @@ async fn handle_inspect(args: &InspectArgs) -> Result<()> { } #[allow(clippy::disallowed_methods)] // CLI is the presentation boundary -async fn handle_verify(args: &VerifyArgs) -> Result<()> { +async fn handle_verify(args: &VerifyArgs, caps: &Capabilities) -> Result<()> { let cache_path = dirs::home_dir() .map(|h| h.join(".auths").join("log_checkpoint.json")) .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; @@ -147,8 +150,11 @@ async fn handle_verify(args: &VerifyArgs) -> Result<()> { }; let registry_url = args.registry.trim_end_matches('/'); - let client = - HttpRegistryClient::new_with_timeouts(Duration::from_secs(30), Duration::from_secs(60)); + let client = HttpRegistryClient::new_with_timeouts( + Duration::from_secs(30), + Duration::from_secs(60), + caps.net_connect.clone(), + ); let response_bytes = client .fetch_registry_data(registry_url, "v1/log/checkpoint") diff --git a/crates/auths-cli/src/commands/org.rs b/crates/auths-cli/src/commands/org.rs index 8749750f..00ddb085 100644 --- a/crates/auths-cli/src/commands/org.rs +++ b/crates/auths-cli/src/commands/org.rs @@ -241,7 +241,7 @@ pub fn handle_org( let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone()); if repo_path.exists() { - match open_git_repo(&repo_path) { + match open_git_repo(&repo_path, &ctx.caps) { Ok(_) => { println!(" Git repository found."); if identity_storage_check.load_identity().is_ok() { @@ -253,13 +253,13 @@ pub fn handle_org( } Err(_) => { println!(" Path exists but is not a Git repo. Initializing..."); - ensure_git_repo(&repo_path) + ensure_git_repo(&repo_path, &ctx.caps) .context("Failed to initialize Git repository")?; } } } else { println!(" Creating Git repo directory..."); - ensure_git_repo(&repo_path) + ensure_git_repo(&repo_path, &ctx.caps) .context("Failed to create and initialize Git repository")?; } diff --git a/crates/auths-cli/src/commands/provision.rs b/crates/auths-cli/src/commands/provision.rs index bb0ddc3a..87c51a94 100644 --- a/crates/auths-cli/src/commands/provision.rs +++ b/crates/auths-cli/src/commands/provision.rs @@ -4,6 +4,7 @@ //! to match. Secrets are handled via environment variable overrides layered //! automatically by the `config` crate, never passed as CLI arguments. +use crate::config::Capabilities; use crate::ux::format::Output; use anyhow::{Context, Result, anyhow}; use auths_core::signing::PassphraseProvider; @@ -62,6 +63,7 @@ pub struct ProvisionCommand { pub fn handle_provision( cmd: ProvisionCommand, passphrase_provider: Arc, + caps: &Capabilities, ) -> Result<()> { let out = Output::new(); let config = load_node_config(&cmd.config)?; @@ -74,7 +76,7 @@ pub fn handle_provision( out.println("================"); out.newline(); - validate_storage_perimeter(&config.identity, &out)?; + validate_storage_perimeter(&config.identity, &out, caps)?; out.print_info("Initializing identity..."); let repo_path = Path::new(&config.identity.repo_path); @@ -200,13 +202,17 @@ fn display_resolved_state(config: &NodeConfig, out: &Output) -> Result<()> { } /// Ensure the repo directory exists and contains a Git repository. -fn validate_storage_perimeter(identity: &IdentityConfig, out: &Output) -> Result<()> { +fn validate_storage_perimeter( + identity: &IdentityConfig, + out: &Output, + caps: &Capabilities, +) -> Result<()> { use crate::factories::storage::{ensure_git_repo, open_git_repo}; let repo_path = Path::new(&identity.repo_path); if repo_path.exists() { - match open_git_repo(repo_path) { + match open_git_repo(repo_path, caps) { Ok(_) => { out.println(&format!( " Repository: {} ({})", @@ -216,7 +222,7 @@ fn validate_storage_perimeter(identity: &IdentityConfig, out: &Output) -> Result } Err(_) => { out.print_info("Initializing Git repository..."); - ensure_git_repo(repo_path) + ensure_git_repo(repo_path, caps) .with_context(|| format!("Failed to init Git repository at {:?}", repo_path))?; out.println(&format!( " Repository: {} ({})", @@ -227,7 +233,7 @@ fn validate_storage_perimeter(identity: &IdentityConfig, out: &Output) -> Result } } else { out.print_info("Creating directory and Git repository..."); - ensure_git_repo(repo_path).with_context(|| { + ensure_git_repo(repo_path, caps).with_context(|| { format!( "Failed to create and init Git repository at {:?}", repo_path @@ -279,7 +285,7 @@ fn print_provision_summary(config: &NodeConfig, out: &Output) { impl crate::commands::executable::ExecutableCommand for ProvisionCommand { fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { - handle_provision(self.clone(), ctx.passphrase_provider.clone()) + handle_provision(self.clone(), ctx.passphrase_provider.clone(), &ctx.caps) } } diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index a0cfa7ab..566664ac 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -9,6 +9,7 @@ use auths_core::config::EnvironmentConfig; use auths_core::signing::PassphraseProvider; use super::artifact::sign::handle_sign as handle_artifact_sign; +use crate::config::Capabilities; /// Represents the resolved target for a sign operation. pub enum SignTarget { @@ -127,6 +128,7 @@ pub fn handle_sign_unified( repo_opt: Option, passphrase_provider: Arc, env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Result<()> { match parse_sign_target(&cmd.target) { SignTarget::Artifact(path) => { @@ -144,6 +146,7 @@ pub fn handle_sign_unified( repo_opt, passphrase_provider, env_config, + caps, ) } SignTarget::CommitRange(range) => sign_commit_range(&range), @@ -157,6 +160,7 @@ impl crate::commands::executable::ExecutableCommand for SignCommand { ctx.repo_path.clone(), ctx.passphrase_provider.clone(), &ctx.env_config, + &ctx.caps, ) } } diff --git a/crates/auths-cli/src/commands/status.rs b/crates/auths-cli/src/commands/status.rs index 9beb493c..6ad4ce63 100644 --- a/crates/auths-cli/src/commands/status.rs +++ b/crates/auths-cli/src/commands/status.rs @@ -1,5 +1,6 @@ //! Status overview command for Auths. +use crate::config::Capabilities; use crate::ux::format::{JsonResponse, Output, is_json_mode}; use anyhow::{Result, anyhow}; use auths_core::config::EnvironmentConfig; @@ -85,12 +86,13 @@ pub fn handle_status( _cmd: StatusCommand, repo: Option, env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Result<()> { let now = Utc::now(); let repo_path = resolve_repo_path(repo)?; - let identity = load_identity_status(&repo_path, env_config); + let identity = load_identity_status(&repo_path, env_config, caps); let agent = get_agent_status(); - let devices = load_devices_summary(&repo_path, now); + let devices = load_devices_summary(&repo_path, now, caps); let report = StatusReport { identity, @@ -246,8 +248,9 @@ fn display_device_expiry(expires_at: Option>, out: &Output, now: D fn load_identity_status( repo_path: &PathBuf, env_config: &EnvironmentConfig, + caps: &Capabilities, ) -> Option { - if crate::factories::storage::open_git_repo(repo_path).is_err() { + if crate::factories::storage::open_git_repo(repo_path, caps).is_err() { return None; } @@ -312,7 +315,11 @@ fn get_agent_status() -> AgentStatusInfo { } /// Load devices summary from attestations. -fn load_devices_summary(repo_path: &PathBuf, now: DateTime) -> DevicesSummary { +fn load_devices_summary( + repo_path: &PathBuf, + now: DateTime, + caps: &Capabilities, +) -> DevicesSummary { let empty = DevicesSummary { linked: 0, revoked: 0, @@ -320,7 +327,7 @@ fn load_devices_summary(repo_path: &PathBuf, now: DateTime) -> DevicesSumma devices_detail: Vec::new(), }; - if crate::factories::storage::open_git_repo(repo_path).is_err() { + if crate::factories::storage::open_git_repo(repo_path, caps).is_err() { return empty; } @@ -437,7 +444,12 @@ fn is_process_running(_pid: u32) -> bool { impl crate::commands::executable::ExecutableCommand for StatusCommand { fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { - handle_status(self.clone(), ctx.repo_path.clone(), &ctx.env_config) + handle_status( + self.clone(), + ctx.repo_path.clone(), + &ctx.env_config, + &ctx.caps, + ) } } diff --git a/crates/auths-cli/src/commands/whoami.rs b/crates/auths-cli/src/commands/whoami.rs index 7bf7911e..a1c13bec 100644 --- a/crates/auths-cli/src/commands/whoami.rs +++ b/crates/auths-cli/src/commands/whoami.rs @@ -5,7 +5,7 @@ use auths_storage::git::RegistryIdentityStorage; use clap::Parser; use serde::Serialize; -use crate::config::CliConfig; +use crate::config::{Capabilities, CliConfig}; use crate::ux::format::{JsonResponse, Output, is_json_mode}; /// Show the current identity on this machine. @@ -20,10 +20,14 @@ struct WhoamiResponse { label: Option, } -pub fn handle_whoami(_cmd: WhoamiCommand, repo: Option) -> Result<()> { +pub fn handle_whoami( + _cmd: WhoamiCommand, + repo: Option, + caps: &Capabilities, +) -> Result<()> { let repo_path = layout::resolve_repo_path(repo).map_err(|e| anyhow!(e))?; - if crate::factories::storage::open_git_repo(&repo_path).is_err() { + if crate::factories::storage::open_git_repo(&repo_path, caps).is_err() { if is_json_mode() { JsonResponse::<()>::error( "whoami", @@ -71,6 +75,6 @@ pub fn handle_whoami(_cmd: WhoamiCommand, repo: Option) -> R impl crate::commands::executable::ExecutableCommand for WhoamiCommand { fn execute(&self, ctx: &CliConfig) -> Result<()> { - handle_whoami(self.clone(), ctx.repo_path.clone()) + handle_whoami(self.clone(), ctx.repo_path.clone(), &ctx.caps) } } diff --git a/crates/auths-cli/src/config.rs b/crates/auths-cli/src/config.rs index 3477f24d..26f83bf2 100644 --- a/crates/auths-cli/src/config.rs +++ b/crates/auths-cli/src/config.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use auths_core::config::EnvironmentConfig; use auths_core::signing::PassphraseProvider; +use capsec::SendCap; #[derive(Debug, Clone, Copy, Default, clap::ValueEnum)] pub enum OutputFormat { @@ -11,12 +12,25 @@ pub enum OutputFormat { Json, } +/// Granted capability tokens for I/O operations. +/// +/// Created once at the CLI boundary via `capsec::root()` and threaded into +/// adapter constructors. Domain crates never see these tokens. +#[derive(Clone)] +pub struct Capabilities { + pub fs_read: SendCap, + pub fs_write: SendCap, + pub net_connect: SendCap, + pub spawn: SendCap, +} + pub struct CliConfig { pub repo_path: Option, pub output_format: OutputFormat, pub is_interactive: bool, pub passphrase_provider: Arc, pub env_config: EnvironmentConfig, + pub caps: Capabilities, } impl CliConfig { diff --git a/crates/auths-cli/src/factories/mod.rs b/crates/auths-cli/src/factories/mod.rs index ddf5a7f7..566c02db 100644 --- a/crates/auths-cli/src/factories/mod.rs +++ b/crates/auths-cli/src/factories/mod.rs @@ -14,27 +14,31 @@ use auths_telemetry::TelemetryShutdown; use auths_telemetry::config::{build_sinks_from_config, load_audit_config}; use auths_telemetry::sinks::composite::CompositeSink; +use capsec::CapRoot; + use crate::cli::AuthsCli; -use crate::config::{CliConfig, OutputFormat}; +use crate::config::{Capabilities, CliConfig, OutputFormat}; use crate::core::provider::{CliPassphraseProvider, PrefilledPassphraseProvider}; /// Builds the full CLI configuration from parsed arguments. /// -/// Constructs the passphrase provider and output settings. +/// Constructs the passphrase provider, output settings, and capability tokens. /// This is the composition root — the only place where concrete adapter /// types are instantiated. /// /// Args: /// * `cli`: The parsed CLI arguments. +/// * `cap_root`: The capability root created at the CLI entry point. /// /// Usage: /// ```ignore /// use auths_cli::factories::build_config; /// +/// let cap_root = capsec::root(); /// let cli = AuthsCli::parse(); -/// let config = build_config(&cli)?; +/// let config = build_config(&cli, &cap_root)?; /// ``` -pub fn build_config(cli: &AuthsCli) -> Result { +pub fn build_config(cli: &AuthsCli, cap_root: &CapRoot) -> Result { let is_json = cli.json || matches!(cli.format, OutputFormat::Json); let output_format = if is_json { OutputFormat::Json @@ -59,12 +63,20 @@ pub fn build_config(cli: &AuthsCli) -> Result { let is_interactive = std::io::stdout().is_terminal(); + let caps = Capabilities { + fs_read: cap_root.fs_read().make_send(), + fs_write: cap_root.fs_write().make_send(), + net_connect: cap_root.net_connect().make_send(), + spawn: cap_root.spawn().make_send(), + }; + Ok(CliConfig { repo_path: cli.repo.clone(), output_format, is_interactive, passphrase_provider, env_config, + caps, }) } diff --git a/crates/auths-cli/src/factories/storage.rs b/crates/auths-cli/src/factories/storage.rs index bc8aaa9a..f89142c4 100644 --- a/crates/auths-cli/src/factories/storage.rs +++ b/crates/auths-cli/src/factories/storage.rs @@ -17,32 +17,36 @@ use auths_storage::git::{ GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage, }; +use crate::config::Capabilities; + /// Opens an existing Git repository at the given path. /// /// Args: /// * `path`: Filesystem path to the repository root. +/// * `caps`: Granted capability tokens for filesystem I/O. /// /// Usage: /// ```ignore /// use auths_cli::factories::storage::open_git_repo; /// -/// let repo = open_git_repo(Path::new("/home/user/.auths"))?; +/// let repo = open_git_repo(Path::new("/home/user/.auths"), &caps)?; /// ``` -pub fn open_git_repo(path: &Path) -> Result { - GitRepo::open(path) +pub fn open_git_repo(path: &Path, caps: &Capabilities) -> Result { + GitRepo::open(path, caps.fs_read.clone(), caps.fs_write.clone()) } /// Initializes a new Git repository at the given path. /// /// Args: /// * `path`: Filesystem path where the repository will be created. +/// * `caps`: Granted capability tokens for filesystem I/O. /// /// Usage: /// ```ignore -/// let repo = init_git_repo(Path::new("/tmp/new-repo"))?; +/// let repo = init_git_repo(Path::new("/tmp/new-repo"), &caps)?; /// ``` -pub fn init_git_repo(path: &Path) -> Result { - GitRepo::init(path) +pub fn init_git_repo(path: &Path, caps: &Capabilities) -> Result { + GitRepo::init(path, caps.fs_read.clone(), caps.fs_write.clone()) } /// Opens an existing Git repository or initializes a new one. @@ -53,21 +57,22 @@ pub fn init_git_repo(path: &Path) -> Result { /// /// Args: /// * `path`: Filesystem path to open or create a repository at. +/// * `caps`: Granted capability tokens for filesystem I/O. /// /// Usage: /// ```ignore -/// let repo = ensure_git_repo(Path::new("/data/auths"))?; +/// let repo = ensure_git_repo(Path::new("/data/auths"), &caps)?; /// ``` -pub fn ensure_git_repo(path: &Path) -> Result { +pub fn ensure_git_repo(path: &Path, caps: &Capabilities) -> Result { if path.exists() { - match GitRepo::open(path) { + match GitRepo::open(path, caps.fs_read.clone(), caps.fs_write.clone()) { Ok(repo) => Ok(repo), - Err(_) => GitRepo::init(path), + Err(_) => GitRepo::init(path, caps.fs_read.clone(), caps.fs_write.clone()), } } else { - std::fs::create_dir_all(path) + capsec::fs::create_dir_all(path, &caps.fs_write) .map_err(|e| StorageError::Io(format!("failed to create directory: {}", e)))?; - GitRepo::init(path) + GitRepo::init(path, caps.fs_read.clone(), caps.fs_write.clone()) } } @@ -102,15 +107,17 @@ pub fn discover_git_repo(start_path: &Path) -> Result>, + _caps: &Capabilities, ) -> Result { let backend: Arc = Arc::new( GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(repo_path)), diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index 29d54812..0257ab89 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -32,6 +32,8 @@ fn audit_action(command: &RootCommand) -> Option<&'static str> { fn run() -> Result<()> { env_logger::init(); + let cap_root = capsec::root(); + let _telemetry = init_audit_sinks(); let cli = AuthsCli::parse(); @@ -57,7 +59,7 @@ fn run() -> Result<()> { set_json_mode(true); } - let ctx = build_config(&cli)?; + let ctx = build_config(&cli, &cap_root)?; let command = match cli.command { Some(cmd) => cmd, diff --git a/crates/auths-infra-git/Cargo.toml b/crates/auths-infra-git/Cargo.toml index 97ed2b25..c9f7b010 100644 --- a/crates/auths-infra-git/Cargo.toml +++ b/crates/auths-infra-git/Cargo.toml @@ -14,6 +14,7 @@ homepage.workspace = true auths-core = { workspace = true } auths-sdk = { workspace = true } auths-verifier = { workspace = true, features = ["native"] } +capsec.workspace = true git2.workspace = true thiserror.workspace = true chrono = "0.4" @@ -22,6 +23,7 @@ log = "0.4" [dev-dependencies] auths-sdk = { workspace = true, features = ["test-utils"] } auths-test-utils = { path = "../auths-test-utils" } +capsec.workspace = true tempfile = "3" [lints] diff --git a/crates/auths-infra-git/src/repo.rs b/crates/auths-infra-git/src/repo.rs index a4d367fa..8b623a6f 100644 --- a/crates/auths-infra-git/src/repo.rs +++ b/crates/auths-infra-git/src/repo.rs @@ -1,4 +1,5 @@ use auths_core::ports::storage::StorageError; +use capsec::SendCap; use git2::Repository; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -9,15 +10,24 @@ use std::sync::Mutex; /// required by the storage port traits, since `git2::Repository` is /// not `Sync` by default. /// +/// Holds `SendCap` tokens to document that this adapter performs filesystem I/O. +/// The actual I/O is delegated to `git2` (libgit2), which cannot be capsec-gated; +/// the tokens enforce that only code granted FS capabilities can construct a `GitRepo`. +/// /// Usage: /// ```ignore /// use auths_infra_git::GitRepo; /// -/// let repo = GitRepo::open("/path/to/repo")?; +/// let cap_root = capsec::test_root(); +/// let fs_read = cap_root.fs_read().make_send(); +/// let fs_write = cap_root.fs_write().make_send(); +/// let repo = GitRepo::open("/path/to/repo", fs_read, fs_write)?; /// ``` pub struct GitRepo { inner: Mutex, path: PathBuf, + _fs_read: SendCap, + _fs_write: SendCap, } impl GitRepo { @@ -25,17 +35,26 @@ impl GitRepo { /// /// Args: /// * `path`: Filesystem path to the repository root. + /// * `fs_read`: Capability token proving the caller has filesystem read permission. + /// * `fs_write`: Capability token proving the caller has filesystem write permission. /// /// Usage: /// ```ignore - /// let repo = GitRepo::open("/home/user/.auths")?; + /// let cap_root = capsec::test_root(); + /// let repo = GitRepo::open("/home/user/.auths", cap_root.fs_read().make_send(), cap_root.fs_write().make_send())?; /// ``` - pub fn open(path: impl AsRef) -> Result { + pub fn open( + path: impl AsRef, + fs_read: SendCap, + fs_write: SendCap, + ) -> Result { let path = path.as_ref().to_path_buf(); let inner = Repository::open(&path).map_err(|e| StorageError::Io(e.to_string()))?; Ok(Self { inner: Mutex::new(inner), path, + _fs_read: fs_read, + _fs_write: fs_write, }) } @@ -43,17 +62,26 @@ impl GitRepo { /// /// Args: /// * `path`: Filesystem path where the repository will be created. + /// * `fs_read`: Capability token proving the caller has filesystem read permission. + /// * `fs_write`: Capability token proving the caller has filesystem write permission. /// /// Usage: /// ```ignore - /// let repo = GitRepo::init("/tmp/new-repo")?; + /// let cap_root = capsec::test_root(); + /// let repo = GitRepo::init("/tmp/new-repo", cap_root.fs_read().make_send(), cap_root.fs_write().make_send())?; /// ``` - pub fn init(path: impl AsRef) -> Result { + pub fn init( + path: impl AsRef, + fs_read: SendCap, + fs_write: SendCap, + ) -> Result { let path = path.as_ref().to_path_buf(); let inner = Repository::init(&path).map_err(|e| StorageError::Io(e.to_string()))?; Ok(Self { inner: Mutex::new(inner), path, + _fs_read: fs_read, + _fs_write: fs_write, }) } diff --git a/crates/auths-infra-git/tests/cases/blob_store.rs b/crates/auths-infra-git/tests/cases/blob_store.rs index 79e37b83..bdefe47f 100644 --- a/crates/auths-infra-git/tests/cases/blob_store.rs +++ b/crates/auths-infra-git/tests/cases/blob_store.rs @@ -3,7 +3,8 @@ use auths_infra_git::{GitBlobStore, GitRepo}; fn setup() -> (tempfile::TempDir, GitRepo) { let (dir, _repo) = auths_test_utils::git::init_test_repo(); - let git_repo = GitRepo::open(dir.path()).unwrap(); + let caps = auths_test_utils::caps::test_caps(); + let git_repo = GitRepo::open(dir.path(), caps.fs_read, caps.fs_write).unwrap(); (dir, git_repo) } diff --git a/crates/auths-infra-git/tests/cases/event_log.rs b/crates/auths-infra-git/tests/cases/event_log.rs index 99ccff82..ebc04595 100644 --- a/crates/auths-infra-git/tests/cases/event_log.rs +++ b/crates/auths-infra-git/tests/cases/event_log.rs @@ -4,7 +4,8 @@ use auths_verifier::keri::Prefix; fn setup() -> (tempfile::TempDir, GitRepo) { let (dir, _repo) = auths_test_utils::git::init_test_repo(); - let git_repo = GitRepo::open(dir.path()).unwrap(); + let caps = auths_test_utils::caps::test_caps(); + let git_repo = GitRepo::open(dir.path(), caps.fs_read, caps.fs_write).unwrap(); (dir, git_repo) } diff --git a/crates/auths-infra-git/tests/cases/ref_store.rs b/crates/auths-infra-git/tests/cases/ref_store.rs index d9c242d7..14de7f05 100644 --- a/crates/auths-infra-git/tests/cases/ref_store.rs +++ b/crates/auths-infra-git/tests/cases/ref_store.rs @@ -3,7 +3,8 @@ use auths_infra_git::{GitRefStore, GitRepo}; fn setup() -> (tempfile::TempDir, GitRepo) { let (dir, _repo) = auths_test_utils::git::init_test_repo(); - let git_repo = GitRepo::open(dir.path()).unwrap(); + let caps = auths_test_utils::caps::test_caps(); + let git_repo = GitRepo::open(dir.path(), caps.fs_read, caps.fs_write).unwrap(); (dir, git_repo) } diff --git a/crates/auths-infra-http/Cargo.toml b/crates/auths-infra-http/Cargo.toml index 4e2d88cd..bb3c7506 100644 --- a/crates/auths-infra-http/Cargo.toml +++ b/crates/auths-infra-http/Cargo.toml @@ -14,6 +14,7 @@ homepage.workspace = true async-trait = "0.1" auths-core = { workspace = true } auths-verifier = { workspace = true, features = ["native"] } +capsec.workspace = true futures-util = "0.3" reqwest = { version = "0.13.2", features = ["json", "form"] } thiserror.workspace = true diff --git a/crates/auths-infra-http/src/registry_client.rs b/crates/auths-infra-http/src/registry_client.rs index 9f7f23d0..2b77f309 100644 --- a/crates/auths-infra-http/src/registry_client.rs +++ b/crates/auths-infra-http/src/registry_client.rs @@ -1,4 +1,5 @@ use auths_core::ports::network::{NetworkError, RateLimitInfo, RegistryClient, RegistryResponse}; +use capsec::SendCap; use std::future::Future; use std::time::Duration; @@ -13,21 +14,28 @@ use crate::{default_client_builder, default_http_client}; /// Fetches and pushes data to a remote registry service for identity /// and attestation synchronization. /// +/// Holds a `SendCap` token to document that this adapter performs +/// network I/O. The actual I/O is delegated to `reqwest`, which cannot be +/// capsec-gated directly; the token enforces that only code granted network +/// capabilities can construct an `HttpRegistryClient`. +/// /// Usage: /// ```ignore /// use auths_infra_http::HttpRegistryClient; /// -/// let client = HttpRegistryClient::new(); -/// let data = client.fetch_registry_data("https://registry.example.com", "identities/abc").await?; +/// let cap_root = capsec::test_root(); +/// let client = HttpRegistryClient::new(cap_root.net_connect().make_send()); /// ``` pub struct HttpRegistryClient { client: reqwest::Client, + _net_cap: SendCap, } impl HttpRegistryClient { - pub fn new() -> Self { + pub fn new(net_cap: SendCap) -> Self { Self { client: default_http_client(), + _net_cap: net_cap, } } @@ -36,29 +44,33 @@ impl HttpRegistryClient { /// Args: /// * `connect_timeout`: Maximum time to establish a TCP connection. /// * `request_timeout`: Maximum total time for the request to complete. + /// * `net_cap`: Capability token proving the caller has network connect permission. /// /// Usage: /// ```ignore + /// let cap_root = capsec::test_root(); /// let client = HttpRegistryClient::new_with_timeouts( /// Duration::from_secs(30), /// Duration::from_secs(60), + /// cap_root.net_connect().make_send(), /// ); /// ``` // INVARIANT: reqwest builder with these settings cannot fail #[allow(clippy::expect_used)] - pub fn new_with_timeouts(connect_timeout: Duration, request_timeout: Duration) -> Self { + pub fn new_with_timeouts( + connect_timeout: Duration, + request_timeout: Duration, + net_cap: SendCap, + ) -> Self { let client = default_client_builder() .connect_timeout(connect_timeout) .timeout(request_timeout) .build() .expect("failed to build HTTP client"); - Self { client } - } -} - -impl Default for HttpRegistryClient { - fn default() -> Self { - Self::new() + Self { + client, + _net_cap: net_cap, + } } } diff --git a/crates/auths-test-utils/Cargo.toml b/crates/auths-test-utils/Cargo.toml index f2faa47a..a125c554 100644 --- a/crates/auths-test-utils/Cargo.toml +++ b/crates/auths-test-utils/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] auths-crypto = { path = "../auths-crypto", features = ["test-utils"] } +capsec.workspace = true git2.workspace = true ring = "0.17.14" tempfile = "3" diff --git a/crates/auths-test-utils/src/caps.rs b/crates/auths-test-utils/src/caps.rs new file mode 100644 index 00000000..f8407022 --- /dev/null +++ b/crates/auths-test-utils/src/caps.rs @@ -0,0 +1,28 @@ +//! Capsec test helpers for creating capability tokens in tests. + +use capsec::SendCap; + +/// All capability tokens needed by adapter tests. +pub struct TestCaps { + pub fs_read: SendCap, + pub fs_write: SendCap, + pub net_connect: SendCap, + pub spawn: SendCap, +} + +/// Creates a full set of test capability tokens via `capsec::test_root()`. +/// +/// Usage: +/// ```ignore +/// let caps = auths_test_utils::caps::test_caps(); +/// let repo = GitRepo::open(path, caps.fs_read.clone(), caps.fs_write.clone())?; +/// ``` +pub fn test_caps() -> TestCaps { + let root = capsec::test_root(); + TestCaps { + fs_read: root.fs_read().make_send(), + fs_write: root.fs_write().make_send(), + net_connect: root.net_connect().make_send(), + spawn: root.spawn().make_send(), + } +} diff --git a/crates/auths-test-utils/src/lib.rs b/crates/auths-test-utils/src/lib.rs index e3c09a3f..7dce70ea 100644 --- a/crates/auths-test-utils/src/lib.rs +++ b/crates/auths-test-utils/src/lib.rs @@ -1,6 +1,8 @@ //! Shared test utilities for Auths crates. #![allow(clippy::unwrap_used, clippy::expect_used)] +pub mod caps; + pub mod crypto { pub use auths_crypto::testing::create_test_keypair; } diff --git a/docs/architecture/capsec-adoption-roadmap.md b/docs/architecture/capsec-adoption-roadmap.md new file mode 100644 index 00000000..1c00a826 --- /dev/null +++ b/docs/architecture/capsec-adoption-roadmap.md @@ -0,0 +1,310 @@ +# capsec Adoption Roadmap + +Generated from `cargo capsec audit` on 2026-03-21. + +## Principle + +The CLI is the presentation layer — it is *supposed* to do I/O. +The same way `Utc::now()` is banned in domain crates but called freely +at the CLI boundary, `std::fs::read` and `std::process::Command` are +expected in CLI command handlers. The architecture goal is not to +eliminate I/O from the CLI, but to ensure domain crates never do it directly. + +## Summary + +| Category | Count | Action | +|----------|-------|--------| +| Test code | 20 | None — tests do I/O by design | +| CLI env reads | 23 | None — presentation layer reads env vars by design | +| Build tooling (xtask, test-utils) | 51 | None — not shipped, not a security surface | +| Standalone binaries (sign/verify) | 13 | None — separate entry points with their own capsec::root() | +| Server entry points (mcp, pairing) | 13 | None — main() reads env and binds ports by design | +| CLI command handlers | 140 | None — the CLI is the I/O boundary, this is expected | +| Ungated CLI adapters | 13 | **Do** — add SendCap tokens, same pattern as fn-82.4 | +| Domain crate I/O (core, id) | 49 | **Do** — extract behind port traits or add INVARIANT comments | +| Infrastructure crate I/O | 23 | **Do** — add capsec dependency and gate with tokens | + +**260 of 347 findings need no action** (CLI doing its job). +**85 findings are real work**, in 3 buckets. + +--- + +## No Action Needed + +These findings are the system working as designed. + +### Test code (20 findings) + +- `crates/auths-cli/src/bin/verify.rs` (1 FS): `test_find_signer_nonexistent_file()` +- `crates/auths-cli/src/commands/policy.rs` (2 FS): `handle_test()` +- `crates/auths-cli/src/commands/sign.rs` (1 FS): `test_parse_sign_target_file()` +- `crates/auths-cli/src/commands/unified_verify.rs` (1 FS): `test_parse_verify_target_file()` +- `crates/auths-core/src/agent/handle.rs` (1 FS): `test_agent_handle_shutdown()` +- `crates/auths-core/src/trust/pinned.rs` (1 FS): `test_concurrent_access_no_corruption()` +- `crates/auths-id/src/storage/registry/hooks.rs` (13 FS): `test_install_appends_to_existing()`, `test_install_idempotent()`, `test_install_linearity_hook_appends_to_existing()`, `test_install_linearity_hook_idempotent()`, `test_install_linearity_hook_new()`, `test_install_new_hooks()`, `test_uninstall_linearity_hook_preserves_other_content()` + +### CLI env reads (23 findings) + +- `crates/auths-cli/src/commands/device/pair/common.rs` (2 ENV): `hostname()` +- `crates/auths-cli/src/commands/device/verify_attestation.rs` (1 ENV): `resolve_issuer_key()` +- `crates/auths-cli/src/commands/git.rs` (1 ENV): `find_git_dir()` +- `crates/auths-cli/src/commands/id/claim.rs` (1 ENV): `github_client_id()` +- `crates/auths-cli/src/commands/id/migrate.rs` (1 ENV): `handle_migrate_status()` +- `crates/auths-cli/src/commands/init/gather.rs` (3 ENV): `gather_ci_config()` +- `crates/auths-cli/src/commands/init/helpers.rs` (8 ENV): `detect_ci_environment()`, `detect_shell()` +- `crates/auths-cli/src/commands/init/prompts.rs` (2 ENV): `prompt_for_git_scope()`, `run_github_verification()` +- `crates/auths-cli/src/commands/key.rs` (2 ENV): `key_copy_backend()`, `key_import()` +- `crates/auths-cli/src/factories/mod.rs` (1 ENV): `init_audit_sinks()` +- `crates/auths-cli/src/ux/format.rs` (1 ENV): `should_use_colors()` + +### Build tooling (xtask, test-utils) (51 findings) + +- `crates/auths-test-utils/src/git.rs` (3 FS): `copy_directory()` +- `crates/xtask/src/check_clippy_sync.rs` (3 FS): `extract_disallowed_paths()`, `find_crate_clippy_files()` +- `crates/xtask/src/ci_setup.rs` (2 ENV, 9 FS): `add_dir_to_tar()`, `dirs_or_env()`, `run()`, `tar_excludes_sock_files()` +- `crates/xtask/src/gen_docs.rs` (2 FS, 4 PROC): `generate_table()`, `run()` +- `crates/xtask/src/gen_error_docs.rs` (10 FS): `check_or_write()`, `parse_file()`, `run()`, `update_mkdocs_nav()` +- `crates/xtask/src/gen_schema.rs` (3 FS): `run()` +- `crates/xtask/src/schemas.rs` (5 FS): `generate()`, `validate()` +- `crates/xtask/src/shell.rs` (6 PROC): `run_capture()`, `run_capture_env()`, `run_with_stdin()` +- `crates/xtask/src/test_integration.rs` (4 PROC): `run()` + +### Standalone binaries (sign/verify) (13 findings) + +- `crates/auths-cli/src/bin/sign.rs` (2 FS, 4 PROC): `run_delegate_to_ssh_keygen()`, `run_sign()`, `run_verify()` +- `crates/auths-cli/src/bin/verify.rs` (1 FS, 6 PROC): `check_ssh_keygen()`, `find_signer()`, `verify_file()`, `verify_with_ssh_keygen()` + +### Server entry points (mcp, pairing) (13 findings) + +- `crates/auths-mcp-server/src/main.rs` (8 ENV, 1 NET): `main()` +- `crates/auths-mcp-server/src/tools.rs` (2 FS): `execute_read_file()`, `execute_write_file()` +- `crates/auths-pairing-daemon/src/discovery.rs` (2 ENV): `advertise()` + +### CLI command handlers (140 findings) + +- `crates/auths-cli/src/commands/agent/mod.rs` (2 FS): `start_agent()` +- `crates/auths-cli/src/commands/agent/process.rs` (6 FS, 2 PROC): `cleanup_stale_files()`, `cleanup_stale_files_removes_existing()`, `read_pid_file()`, `read_pid_file_invalid_content_errors()`, `spawn_detached()` +- `crates/auths-cli/src/commands/agent/service.rs` (6 FS, 6 PROC): `install_launchd_service()`, `install_systemd_service()`, `uninstall_launchd_service()`, `uninstall_systemd_service()` +- `crates/auths-cli/src/commands/artifact/publish.rs` (1 FS): `handle_publish_async()` +- `crates/auths-cli/src/commands/artifact/sign.rs` (1 FS): `handle_sign()` +- `crates/auths-cli/src/commands/artifact/verify.rs` (3 FS): `handle_verify()`, `resolve_identity_key()`, `verify_witnesses()` +- `crates/auths-cli/src/commands/audit.rs` (1 FS): `handle_audit()` +- `crates/auths-cli/src/commands/device/authorization.rs` (2 FS): `read_payload_file()`, `validate_payload_schema()` +- `crates/auths-cli/src/commands/device/pair/lan_server.rs` (1 NET): `start()` +- `crates/auths-cli/src/commands/device/verify_attestation.rs` (3 FS): `handle_verify_attestation()`, `run_verify()` +- `crates/auths-cli/src/commands/doctor.rs` (1 FS): `check_allowed_signers_file()` +- `crates/auths-cli/src/commands/emergency.rs` (2 FS): `handle_report()` +- `crates/auths-cli/src/commands/git.rs` (6 FS): `find_git_dir()`, `handle_install_hooks()` +- `crates/auths-cli/src/commands/id/bind_idp.rs` (2 PROC): `handle_bind_idp()` +- `crates/auths-cli/src/commands/id/identity.rs` (2 FS): `handle_id()` +- `crates/auths-cli/src/commands/id/migrate.rs` (8 FS, 12 PROC): `analyze_commit_signatures()`, `get_ssh_key_bits()`, `is_gpg_available()`, `list_gpg_secret_keys()`, `list_ssh_keys()`, `parse_ssh_public_key()`, `perform_gpg_migration()`, `perform_ssh_migration()`, `update_allowed_signers()` +- `crates/auths-cli/src/commands/init/gather.rs` (1 FS): `ensure_registry_dir()` +- `crates/auths-cli/src/commands/init/helpers.rs` (3 FS, 6 PROC): `check_git_version()`, `install_shell_completions()`, `set_git_config()`, `write_allowed_signers()` +- `crates/auths-cli/src/commands/key.rs` (1 FS): `key_import()` +- `crates/auths-cli/src/commands/learn.rs` (7 FS, 12 PROC): `cleanup_sandbox()`, `load_progress()`, `reset_progress()`, `save_progress()`, `section_creating_identity()`, `section_signing_commit()`, `setup_sandbox()` +- `crates/auths-cli/src/commands/log.rs` (1 FS): `handle_verify()` +- `crates/auths-cli/src/commands/org.rs` (2 FS, 1 NET): `handle_join()`, `handle_org()` +- `crates/auths-cli/src/commands/policy.rs` (6 FS): `handle_compile()`, `handle_diff()`, `handle_explain()`, `handle_lint()` +- `crates/auths-cli/src/commands/scim.rs` (2 PROC): `handle_serve()` +- `crates/auths-cli/src/commands/sign.rs` (4 PROC): `execute_git_rebase()`, `sign_commit_range()` +- `crates/auths-cli/src/commands/signers.rs` (2 PROC): `resolve_signers_path()` +- `crates/auths-cli/src/commands/status.rs` (1 FS): `get_agent_status()` +- `crates/auths-cli/src/commands/verify_commit.rs` (3 FS, 14 PROC): `check_ssh_keygen()`, `get_commit_signature()`, `resolve_commit_sha()`, `resolve_commits()`, `resolve_signers_source()`, `verify_ssh_signature()`, `verify_witnesses()` +- `crates/auths-cli/src/core/fs.rs` (3 FS): `create_restricted_dir()`, `write_sensitive_file()` +- `crates/auths-cli/src/core/pubkey_cache.rs` (4 FS): `clear_all_cached_pubkeys()`, `clear_cached_pubkey()`, `get_cached_pubkey()` + +--- + +## Priority 1: Ungated CLI Adapters (13 findings) + +Same pattern as fn-82.4. Add `SendCap

` fields to adapter structs, +update constructors to accept tokens, replace `std` calls with `capsec` wrappers. + +### `allowed_signers_store.rs` → needs FsRead, FsWrite +- [ ] `std::fs::read_to_string` in `read()` (line 14, FS) +- [ ] `std::fs::create_dir_all` in `write()` (line 27, FS) + +### `doctor_fixes.rs` → needs FsWrite, Spawn +- [ ] `std::fs::create_dir_all` in `apply()` (line 41, FS) +- [ ] `std::process::Command::new` in `set_git_config_value()` (line 124, PROC **[critical]**) +- [ ] `status` in `set_git_config_value()` (line 126, PROC **[critical]**) + +### `ssh_agent.rs` → needs Spawn +- [ ] `std::process::Command::new` in `register_key()` (line 18, PROC **[critical]**) +- [ ] `output` in `register_key()` (line 20, PROC **[critical]**) + +### `system_diagnostic.rs` → needs Spawn +- [ ] `std::process::Command::new` in `check_git_version()` (line 13, PROC **[critical]**) +- [ ] `output` in `check_git_version()` (line 13, PROC **[critical]**) +- [ ] `std::process::Command::new` in `get_git_config()` (line 30, PROC **[critical]**) +- [ ] `output` in `get_git_config()` (line 32, PROC **[critical]**) +- [ ] `std::process::Command::new` in `check_ssh_keygen_available()` (line 47, PROC **[critical]**) +- [ ] `output` in `check_ssh_keygen_available()` (line 47, PROC **[critical]**) + +--- + +## Priority 2: Domain Crate I/O (49 findings) + +These are `std::fs` and `std::process` calls in auths-core and auths-id — +crates that should ideally be I/O-free. Some are platform-specific storage +with INVARIANT comments (acceptable as-is). Others should be extracted +behind port traits with adapters in the infrastructure layer. + +### `crates/auths-core/src/agent/handle.rs` (FS:2) +**`shutdown()`** +- [ ] `std::fs::remove_file` (line 259, FS **[high]**) +- [ ] `std::fs::remove_file` (line 270, FS **[high]**) + +### `crates/auths-core/src/api/runtime.rs` (FS:2) +**`start_agent_listener_with_handle()`** +- [ ] `std::fs::create_dir_all` (line 739, FS) +- [ ] `std::fs::remove_file` (line 746, FS **[high]**) + +### `crates/auths-core/src/config.rs` (ENV:10) *(environment config loading — acceptable at startup boundary)* +**`from_env()`** +- [ ] `std::env::var` (line 59, ENV) +- [ ] `std::env::var` (line 62, ENV) +- [ ] `std::env::var` (line 65, ENV) +- [ ] `std::env::var` (line 66, ENV) +- [ ] `std::env::var` (line 67, ENV) +- [ ] `std::env::var` (line 116, ENV) +- [ ] `std::env::var` (line 117, ENV) +- [ ] `std::env::var` (line 118, ENV) +- [ ] `std::env::var` (line 162, ENV) +- [ ] `std::env::var` (line 167, ENV) + +### `crates/auths-core/src/storage/encrypted_file.rs` (FS:1) *(platform-specific storage — keep with INVARIANT comments)* +**`read_data()`** +- [ ] `std::fs::File::open` (line 176, FS) + +### `crates/auths-core/src/storage/windows_credential.rs` (FS:3) *(platform-specific storage — keep with INVARIANT comments)* +**`load_index()`** +- [ ] `std::fs::read_to_string` (line 113, FS) + +**`new()`** +- [ ] `std::fs::create_dir_all` (line 91, FS) + +**`save_index()`** +- [ ] `std::fs::write` (line 125, FS **[high]**) + +### `crates/auths-core/src/testing/builder.rs` (PROC:2) *(test infrastructure — acceptable)* +**`build()`** +- [ ] `std::process::Command::new` (line 192, PROC **[critical]**) +- [ ] `output` (line 195, PROC **[critical]**) + +### `crates/auths-core/src/trust/pinned.rs` (FS:5) +**`lock()`** +- [ ] `std::fs::create_dir_all` (line 224, FS) + +**`read_all()`** +- [ ] `std::fs::read_to_string` (line 195, FS) + +**`write_all()`** +- [ ] `std::fs::create_dir_all` (line 207, FS) +- [ ] `std::fs::File::create` (line 211, FS **[high]**) +- [ ] `std::fs::rename` (line 217, FS) + +### `crates/auths-core/src/trust/roots_file.rs` (FS:2) +**`create_temp_roots_file()`** +- [ ] `std::fs::File::create` (line 112, FS **[high]**) + +**`load()`** +- [ ] `std::fs::read_to_string` (line 79, FS) + +### `crates/auths-core/src/witness/server.rs` (NET:1) +**`run_server()`** +- [ ] `tokio::net::TcpListener::bind` (line 269, NET **[high]**) + +### `crates/auths-id/src/agent_identity.rs` (FS:2) +**`ensure_git_repo()`** +- [ ] `std::fs::create_dir_all` (line 217, FS) + +**`write_agent_toml()`** +- [ ] `std::fs::write` (line 378, FS **[high]**) + +### `crates/auths-id/src/freeze.rs` (FS:4) +**`load_active_freeze()`** +- [ ] `std::fs::read_to_string` (line 85, FS) +- [ ] `std::fs::remove_file` (line 91, FS **[high]**) + +**`remove_freeze()`** +- [ ] `std::fs::remove_file` (line 110, FS **[high]**) + +**`store_freeze()`** +- [ ] `std::fs::write` (line 101, FS **[high]**) + +### `crates/auths-id/src/storage/registry/hooks.rs` (FS:15) +**`find_git_dir()`** +- [ ] `std::fs::read_to_string` (line 150, FS) + +**`install_cache_hooks()`** +- [ ] `std::fs::create_dir_all` (line 88, FS) + +**`install_hook()`** +- [ ] `std::fs::read_to_string` (line 107, FS) +- [ ] `std::fs::write` (line 129, FS **[high]**) +- [ ] `std::fs::metadata` (line 132, FS) + +**`install_linearity_hook()`** +- [ ] `std::fs::create_dir_all` (line 299, FS) +- [ ] `std::fs::read_to_string` (line 305, FS) +- [ ] `std::fs::write` (line 327, FS **[high]**) +- [ ] `std::fs::metadata` (line 331, FS) + +**`uninstall_cache_hooks()`** +- [ ] `std::fs::read_to_string` (line 186, FS) +- [ ] `std::fs::remove_file` (line 217, FS **[high]**) +- [ ] `std::fs::write` (line 219, FS **[high]**) + +**`uninstall_linearity_hook()`** +- [ ] `std::fs::read_to_string` (line 359, FS) +- [ ] `std::fs::remove_file` (line 387, FS **[high]**) +- [ ] `std::fs::write` (line 389, FS **[high]**) + +--- + +## Priority 3: Infrastructure Crate I/O (23 findings) + +Legitimate I/O in infrastructure crates. When these crates adopt capsec +as a dependency, add `SendCap

` tokens to their adapter structs — +same pattern as auths-infra-git and auths-infra-http. + +### auths-infra-http (NET:2) +**`crates/auths-infra-http/src/request.rs`** +- [ ] `reqwest::Client::new` in `build_get_creates_get_request()` (line 72, NET) +- [ ] `reqwest::Client::new` in `build_post_creates_post_with_body()` (line 81, NET) + +### auths-sdk (FS:7) +**`crates/auths-sdk/src/workflows/transparency.rs`** +- [ ] `std::fs::read_to_string` in `try_cache_checkpoint()` (line 276, FS) +- [ ] `std::fs::create_dir_all` in `try_cache_checkpoint()` (line 331, FS) +- [ ] `std::fs::write` in `try_cache_checkpoint()` (line 333, FS **[high]**) +- [ ] `std::fs::read_to_string` in `update_checkpoint_cache()` (line 211, FS) +- [ ] `std::fs::create_dir_all` in `update_checkpoint_cache()` (line 236, FS) +- [ ] `std::fs::write` in `update_checkpoint_cache()` (line 238, FS **[high]**) +- [ ] `std::fs::read_to_string` in `update_checkpoint_cache_writes_new_file()` (line 472, FS) + +### auths-storage (FS:8) +**`crates/auths-storage/src/git/adapter.rs`** +- [ ] `std::fs::File::create` in `acquire()` (line 106, FS **[high]**) +- [ ] `std::fs::create_dir_all` in `init_if_needed()` (line 234, FS) +- [ ] `std::fs::read` in `load_tenant_metadata()` (line 323, FS) + +**`crates/auths-storage/src/git/vfs.rs`** +- [ ] `std::fs::remove_file` in `delete_file()` (line 107, FS **[high]**) +- [ ] `std::fs::rename` in `persist_temp_file()` (line 157, FS) +- [ ] `std::fs::copy` in `persist_temp_file()` (line 161, FS) +- [ ] `std::fs::remove_file` in `persist_temp_file()` (line 162, FS **[high]**) +- [ ] `std::fs::read` in `read_file()` (line 89, FS) + +### auths-telemetry (FS:2) +**`crates/auths-telemetry/src/config.rs`** +- [ ] `std::fs::create_dir_all` in `build_file_sink()` (line 220, FS) +- [ ] `std::fs::read_to_string` in `load_audit_config()` (line 95, FS) + +### auths-transparency (FS:4) +**`crates/auths-transparency/src/fs_store.rs`** +- [ ] `tokio::fs::read` in `read_checkpoint()` (line 73, FS) +- [ ] `tokio::fs::read` in `read_tile()` (line 48, FS) +- [ ] `tokio::fs::write` in `write_checkpoint()` (line 89, FS **[high]**) +- [ ] `tokio::fs::write` in `write_tile()` (line 66, FS **[high]**)