diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..e597b21 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,127 @@ +# the name by which the project can be referenced within Serena +project_name: "vyn" + + +# list of languages for which language servers are started; choose from: +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- rust + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: diff --git a/.vynignore b/.vynignore index 93ee1b2..717d2bd 100644 --- a/.vynignore +++ b/.vynignore @@ -1,18 +1,6 @@ -# vyn ignore rules example, processed by `ignore` crate +# vyn opt-in model: everything is ignored unless explicitly included. +# Add '!pattern' lines below to track additional files. -## Ignore local artifacts and build outputs -.vyn/ -.git/ -target/ +# Ignore everything by default +* -## Common local env files you may not want to sync -.env.local -.env.*.local - -## Temporary files -*.tmp -*.swp - - ## Dir - crates/ - proto/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 72cb204..d5f37dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5392,7 +5392,7 @@ checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "vyn-cli" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "clap", @@ -5417,7 +5417,7 @@ dependencies = [ [[package]] name = "vyn-core" -version = "0.1.3" +version = "0.1.4" dependencies = [ "age", "anyhow", @@ -5445,7 +5445,7 @@ dependencies = [ [[package]] name = "vyn-relay" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index c551645..189d05e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -version = "0.1.3" +version = "0.1.4" repository = "https://github.com/arnonsang/vyn" [workspace.dependencies] diff --git a/crates/vyn-cli/Cargo.toml b/crates/vyn-cli/Cargo.toml index 0b11ac7..e9bb27a 100644 --- a/crates/vyn-cli/Cargo.toml +++ b/crates/vyn-cli/Cargo.toml @@ -29,5 +29,5 @@ secrecy.workspace = true tokio.workspace = true toml.workspace = true uuid.workspace = true -vyn-core = { path = "../vyn-core", version = "0.1.3" } -vyn-relay = { path = "../vyn-relay", version = "0.1.3" } +vyn-core = { path = "../vyn-core", version = "0.1.4" } +vyn-relay = { path = "../vyn-relay", version = "0.1.4" } diff --git a/crates/vyn-cli/src/commands/history.rs b/crates/vyn-cli/src/commands/history.rs index 147d032..5c7fb97 100644 --- a/crates/vyn-cli/src/commands/history.rs +++ b/crates/vyn-cli/src/commands/history.rs @@ -45,7 +45,7 @@ pub fn run() -> Result<()> { } } - entries.sort_by(|a, b| b.timestamp_unix.cmp(&a.timestamp_unix)); + entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp_unix)); if entries.is_empty() { output::print_warning("no history found"); diff --git a/crates/vyn-cli/src/commands/init.rs b/crates/vyn-cli/src/commands/init.rs index a36d4ba..622f835 100644 --- a/crates/vyn-cli/src/commands/init.rs +++ b/crates/vyn-cli/src/commands/init.rs @@ -231,13 +231,6 @@ fn write_vynignore_interactive(root: &Path, vynignore_path: &Path) -> Result<()> } } - // Artifact patterns are always excluded, no prompt needed. - let artifact_patterns: Vec<&str> = patterns - .iter() - .copied() - .filter(|p| is_artifact(p)) - .collect(); - println!(); println!( " {} Scanning git-ignored files…", @@ -308,19 +301,13 @@ fn write_vynignore_interactive(root: &Path, vynignore_path: &Path) -> Result<()> if secret_candidates.is_empty() && other_candidates.is_empty() { println!(" No git-ignored files detected on disk. Excluding all gitignore patterns."); println!(); - let content = build_vynignore_content(&patterns, &[]); + let content = build_vynignore_content(&[]); fs::write(vynignore_path, content).context("failed to write .vynignore")?; return Ok(()); } - // Patterns to exclude = all gitignore patterns minus what the user wants to track. - let excluded_patterns: Vec<&str> = patterns - .iter() - .copied() - .filter(|p| !tracked_patterns.iter().any(|t| t == p)) - .collect(); - - let content = build_vynignore_content(&excluded_patterns, &artifact_patterns); + let tracked_refs: Vec<&str> = tracked_patterns.iter().map(|s| s.as_str()).collect(); + let content = build_vynignore_content(&tracked_refs); fs::write(vynignore_path, &content).context("failed to write .vynignore")?; println!(); @@ -346,25 +333,22 @@ fn write_vynignore_interactive(root: &Path, vynignore_path: &Path) -> Result<()> Ok(()) } -/// Build the `.vynignore` file content from the list of patterns to exclude. -/// Always prepends the mandatory hardcoded exclusions. -fn build_vynignore_content(excluded: &[&str], _artifact_patterns: &[&str]) -> String { +fn build_vynignore_content(tracked: &[&str]) -> String { let mut lines = vec![ "# Generated by vyn init".to_string(), - "# Patterns listed here are excluded from vyn tracking.".to_string(), - "# Remove a pattern to start tracking those files.".to_string(), + "# vyn uses an opt-in model: everything is ignored unless explicitly included.".to_string(), + "# Add '!pattern' lines below to track additional files.".to_string(), String::new(), - "# vyn internals".to_string(), - ".vyn/".to_string(), - ".git/".to_string(), - String::new(), - "# Excluded patterns (from .gitignore)".to_string(), + "# Ignore everything by default".to_string(), + "*".to_string(), ]; - - for p in excluded { - lines.push(p.to_string()); + if !tracked.is_empty() { + lines.push(String::new()); + lines.push("# Tracked patterns (selected during vyn init)".to_string()); + for p in tracked { + lines.push(format!("!{p}")); + } } - lines.push(String::new()); lines.join("\n") } diff --git a/crates/vyn-cli/src/commands/link.rs b/crates/vyn-cli/src/commands/link.rs index b1423d7..5cbe25a 100644 --- a/crates/vyn-cli/src/commands/link.rs +++ b/crates/vyn-cli/src/commands/link.rs @@ -37,10 +37,9 @@ pub fn run(vault_id: String) -> Result<()> { let runtime = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; runtime.block_on(async { let provider = RelayStorageProvider::new(relay_url); - provider - .authenticate_with_identity(&vault_dir) - .await - .context("relay authentication failed (run `vyn auth` first)")?; + let spinner = output::new_spinner("authenticating with relay..."); + provider.authenticate_with_identity(&vault_dir).await?; + output::finish_progress(&spinner, "authenticated"); let spinner = output::new_spinner("fetching invite from relay…"); let invites = provider @@ -138,5 +137,5 @@ fn load_relay_url(root: &Path) -> Result { .with_context(|| format!("missing or unreadable file: {}", config_path.display()))?; let cfg: VaultConfig = toml::from_str(&text).context("invalid .vyn/config.toml format")?; cfg.relay_url - .context("missing `relay_url` in .vyn/config.toml — run `vyn config` to set it") + .context("missing `relay_url` in .vyn/config.toml - run `vyn config` to set it") } diff --git a/crates/vyn-cli/src/commands/pull.rs b/crates/vyn-cli/src/commands/pull.rs index a6295f8..d462f88 100644 --- a/crates/vyn-cli/src/commands/pull.rs +++ b/crates/vyn-cli/src/commands/pull.rs @@ -173,10 +173,24 @@ async fn provider_for_config(config: &VaultConfig, vault_dir: &Path) -> Result

anyhow::bail!( diff --git a/crates/vyn-cli/src/commands/push.rs b/crates/vyn-cli/src/commands/push.rs index 85b83c4..ec0d9e1 100644 --- a/crates/vyn-cli/src/commands/push.rs +++ b/crates/vyn-cli/src/commands/push.rs @@ -155,10 +155,24 @@ async fn provider_for_config(config: &VaultConfig, vault_dir: &Path) -> Result

anyhow::bail!( diff --git a/crates/vyn-cli/src/commands/rotate.rs b/crates/vyn-cli/src/commands/rotate.rs index 4c641d7..e595466 100644 --- a/crates/vyn-cli/src/commands/rotate.rs +++ b/crates/vyn-cli/src/commands/rotate.rs @@ -222,10 +222,24 @@ async fn provider_for_config(config: &VaultConfig, vault_dir: &Path) -> Result

anyhow::bail!( diff --git a/crates/vyn-cli/src/commands/share.rs b/crates/vyn-cli/src/commands/share.rs index d7e43fd..cbb5ee0 100644 --- a/crates/vyn-cli/src/commands/share.rs +++ b/crates/vyn-cli/src/commands/share.rs @@ -44,7 +44,7 @@ pub fn run(user: String) -> Result<()> { let relay_url = config .relay_url .clone() - .context("missing `relay_url` in .vyn/config.toml — run `vyn config` to set it")?; + .context("missing `relay_url` in .vyn/config.toml - run `vyn config` to set it")?; if config.storage_provider != "relay" { anyhow::bail!( @@ -57,10 +57,9 @@ pub fn run(user: String) -> Result<()> { runtime.block_on(async { let relay_url_opt = Some(relay_url.as_str()); let provider = RelayStorageProvider::new(relay_url.clone()); - provider - .authenticate_with_identity(&vault_dir) - .await - .context("relay authentication failed (run `vyn auth` first)")?; + let spinner = output::new_spinner("authenticating with relay..."); + provider.authenticate_with_identity(&vault_dir).await?; + output::finish_progress(&spinner, "authenticated"); let spinner2 = output::new_spinner(&format!("uploading invite(s) for @{username}…")); let mut uploaded = 0usize; diff --git a/crates/vyn-cli/src/commands/status.rs b/crates/vyn-cli/src/commands/status.rs index 0bd9ed5..bc898d1 100644 --- a/crates/vyn-cli/src/commands/status.rs +++ b/crates/vyn-cli/src/commands/status.rs @@ -23,8 +23,10 @@ pub fn run(verbose: bool) -> Result<()> { let (manifest, vault_id) = load_vault_state(&root)?; let matcher = load_ignore_matcher(&root).context("failed to load ignore matcher")?; + let spinner = output::new_spinner("scanning files..."); let current = capture_manifest(&root, &matcher).context("failed to capture current manifest")?; + output::finish_progress(&spinner, &format!("{} files scanned", current.files.len())); let old_by_path = map_by_path(&manifest.files); let new_by_path = map_by_path(¤t.files); diff --git a/crates/vyn-core/Cargo.toml b/crates/vyn-core/Cargo.toml index efd26b3..a295d74 100644 --- a/crates/vyn-core/Cargo.toml +++ b/crates/vyn-core/Cargo.toml @@ -27,9 +27,9 @@ toml.workspace = true tempfile = "3" tokio-stream = "0.1" tonic.workspace = true -vyn-relay = { path = "../vyn-relay", version = "0.1.3" } +vyn-relay = { path = "../vyn-relay", version = "0.1.4" } walkdir.workspace = true [dev-dependencies] uuid.workspace = true -vyn-relay = { path = "../vyn-relay", version = "0.1.3", features = ["test-bypass-auth"] } +vyn-relay = { path = "../vyn-relay", version = "0.1.4", features = ["test-bypass-auth"] } diff --git a/crates/vyn-core/src/relay_storage.rs b/crates/vyn-core/src/relay_storage.rs index 6b368cc..2bc567c 100644 --- a/crates/vyn-core/src/relay_storage.rs +++ b/crates/vyn-core/src/relay_storage.rs @@ -78,6 +78,17 @@ impl RelayStorageProvider { /// Register identity on the relay (idempotent) then authenticate. /// `vault_dir` is the `.vyn/` directory (contains `identity.toml`). pub async fn authenticate_with_identity(&self, vault_dir: &Path) -> StorageResult<()> { + // Check for a cached session token first. + let token_path = vault_dir.join("session.token"); + if token_path.exists() { + let cached = std::fs::read_to_string(&token_path).unwrap_or_default(); + let cached = cached.trim().to_string(); + if !cached.is_empty() { + *self.token.write().await = Some(cached); + return Ok(()); + } + } + let identity = load_identity(vault_dir)?; let private_key_path = identity.ssh_private_key.clone(); let public_key_path = identity.ssh_public_key.clone(); @@ -95,7 +106,18 @@ impl RelayStorageProvider { self.authenticate(&user_id, move |nonce| { sign_nonce_with_ssh_key(nonce, Path::new(&private_key_path2)) }) - .await + .await?; + + // Cache the session token for future runs. + if let Some(ref tok) = *self.token.read().await { + let _ = write_token_file(&token_path, tok); + } + Ok(()) + } + + pub fn clear_cached_token(vault_dir: &Path) { + let token_path = vault_dir.join("session.token"); + let _ = std::fs::remove_file(token_path); } async fn ensure_identity_registered( @@ -323,6 +345,16 @@ fn parse_toml_string(text: &str, key: &str) -> Option { None } +fn write_token_file(path: &Path, token: &str) -> std::io::Result<()> { + std::fs::write(path, token)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + fn sign_nonce_with_ssh_key(nonce: &[u8], private_key: &Path) -> StorageResult { let tmp = tempfile::TempDir::new() .map_err(|e| StorageError::Transport(format!("failed to create temp dir: {e}")))?; diff --git a/crates/vyn-relay/src/service.rs b/crates/vyn-relay/src/service.rs index 590b7d6..ce6715a 100644 --- a/crates/vyn-relay/src/service.rs +++ b/crates/vyn-relay/src/service.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -16,6 +16,7 @@ use crate::proto::*; use crate::store::{FileStore, sanitize_id}; const CHALLENGE_TTL: Duration = Duration::from_secs(60); +const SESSION_TTL: Duration = Duration::from_secs(86400); type ChallengeMap = Arc, Instant)>>>; @@ -23,7 +24,7 @@ type ChallengeMap = Arc, Instant)>>>; pub struct RelayService { store: FileStore, challenges: ChallengeMap, - sessions: Arc>>, + sessions: Arc>>, } impl RelayService { @@ -31,13 +32,16 @@ impl RelayService { Self { store, challenges: Arc::new(RwLock::new(HashMap::new())), - sessions: Arc::new(RwLock::new(HashSet::new())), + sessions: Arc::new(RwLock::new(HashMap::new())), } } } #[allow(clippy::result_large_err)] -fn require_auth(request: &Request, sessions: &HashSet) -> Result<(), Status> { +fn require_auth( + request: &Request, + sessions: &HashMap, +) -> Result<(), Status> { // When the test-bypass-auth feature is enabled, allow a magic token so // integration tests can exercise storage without a real SSH key setup. #[cfg(feature = "test-bypass-auth")] @@ -55,10 +59,9 @@ fn require_auth(request: &Request, sessions: &HashSet) -> Result<( .get("x-vyn-token") .and_then(|v| v.to_str().ok()) .ok_or_else(|| Status::unauthenticated("missing x-vyn-token header"))?; - if sessions.contains(token) { - Ok(()) - } else { - Err(Status::unauthenticated("invalid or expired token")) + match sessions.get(token) { + Some(issued_at) if issued_at.elapsed() < SESSION_TTL => Ok(()), + Some(_) | None => Err(Status::unauthenticated("invalid or expired token")), } } @@ -119,7 +122,10 @@ impl VynRelay for RelayService { .fill(&mut raw) .map_err(|_| Status::internal("failed to generate session token"))?; let tok: String = raw.iter().map(|b| format!("{b:02x}")).collect(); - self.sessions.write().await.insert(tok.clone()); + self.sessions + .write() + .await + .insert(tok.clone(), Instant::now()); tok } else { String::new() diff --git a/docs/src/concepts/security.md b/docs/src/concepts/security.md new file mode 100644 index 0000000..3385ca6 --- /dev/null +++ b/docs/src/concepts/security.md @@ -0,0 +1,58 @@ +# Security Notes + +## Encryption + +- All blobs and manifests are encrypted with **AES-256-GCM** before leaving the local machine +- The encryption key (project key) is a 256-bit random key stored in the OS keychain +- The relay and S3 backend never see plaintext content or metadata (zero-knowledge) + +## Key Storage + +Project keys are stored in the OS keychain: + +| Platform | Backend | +|---|---| +| Linux | `keyutils` (kernel keyring) or Secret Service (D-Bus) | +| macOS | macOS Keychain | +| Windows | Windows Credential Manager (DPAPI) | + +Keys are never written to disk in plaintext. + +## Identity + SSH Challenge-Response + +`vyn auth` uses GitHub OAuth Device Flow to establish identity, then proves key ownership via a local `ssh-keygen` sign/verify round-trip. No passwords. Your private key never leaves your machine. + +## Invite Encryption + +Invites (created by `vyn share @user`) are encrypted with the recipient's SSH public key via [age](https://age-encryption.org). Each invite embeds the vault ID, relay URL, and project key - all encrypted specifically for the named recipient. + +## Session Tokens + +After `vyn auth`, the relay issues a 32-byte cryptographically random session token (hex-encoded). This token is: + +- Cached at `.vyn/session.token` with `0600` permissions (owner-read-only on Unix) +- Enforced with a 24-hour TTL server-side - expired tokens require re-auth +- Equivalent in sensitivity to an SSH agent socket; keep `.vyn/` out of Git (handled automatically by `vyn init`) + +## Git Safety + +`vyn init` adds `.vyn/` to `.gitignore` automatically, preventing accidental commit of: + +- `.vyn/config.toml` (contains relay URL and vault ID) +- `.vyn/identity.toml` (SSH key paths) +- `.vyn/session.token` (relay session credential) +- `.vyn/blobs/` (encrypted blob cache) +- `.vyn/manifest.json` (plaintext file list) + +`vyn.toml` (vault ID + relay URL, no secrets) is safe to commit and is intended to be committed. + +## Relay TLS + +The relay does not terminate TLS itself. Use a reverse proxy (nginx, Caddy) for HTTPS. Sending session tokens over plaintext gRPC exposes them to network sniffing - TLS is strongly recommended in production. See [Docker Deployment](../relay/docker.md) and [Relay Overview](../relay/overview.md) for TLS setup. + +## Known Limitations + +- No way to revoke a teammate's access without full key rotation (`vyn rotate`) +- Invite files accumulate on the relay; no expiry mechanism yet +- Session tokens are valid for 24h - a leaked `.vyn/session.token` grants relay access until TTL expires or relay restarts +- `VYN_SKIP_GITHUB_VERIFY=1` disables SSH key verification; only active in debug builds