diff --git a/CHANGELOG.md b/CHANGELOG.md index 7895ad2a..052d1f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.6.1] - 2026-07-02 + +### Security + +- **`aiCommand` is now honored only from the `SPECSYNC_AI_COMMAND` environment variable** — it is no longer read from any config file (committed `.specsync/config.toml`/`specsync.json` or the per-developer `.specsync/config.local.toml`). Because `aiCommand` is run via `sh -c`, sourcing it from a repo file let a malicious repository — delivered by `git clone`, a ZIP/tarball download, or extraction into an existing checkout — achieve arbitrary code execution the moment an AI path ran (`generate`, `check --fix`, or the MCP `generate` tool). If you previously set `aiCommand` in config, export it instead: `export SPECSYNC_AI_COMMAND="…"`. Other `ai_*` fields (provider, model, etc.) are unaffected and still load from config. +- **`files:` entries that escape the project root are now rejected** — a spec `files:` path resolving outside the project via an absolute path (`/etc/passwd`), `..` traversal, or an in-repo symlink pointing out was previously read and validated: the out-of-root file counted as covered and its exported identifiers leaked into `check`/`score`/`diff` output, PR comments, and the MCP tools (a hostile-repo information-disclosure vector). Every site that reads a `files:` entry's content now requires it to resolve inside the project root; out-of-root entries are reported as an error. In-root relative paths and in-root symlinks still work. + +### Fixed + +- **Non-UTF-8 source files no longer pass validation silently** — a file listed in a spec's `files:` that exists but is not valid UTF-8 caused export extraction to yield nothing, so undocumented exports went unchecked and the spec falsely passed. `check` now reports an error naming the file and how to fix it. +- **Incremental `check` re-validates when schema or config files change** — the default cached `check` only re-checked specs whose own tracked files changed, so editing a schema/migration to drop a documented column, or changing the config, was silently skipped as "nothing to validate." Schema-directory files and the config file are now part of the incremental fingerprint, so such changes trigger re-validation. (`check --force` was already correct.) +- **`merge` no longer corrupts or silently drops spec content** — `specsync merge` reported `✓ resolved` while (depending on the conflict shape) deleting the YAML frontmatter fences, deleting the spec body, dropping frontmatter list items, or deleting a table/changelog section. It now auto-resolves only conflict hunks it can merge losslessly — a pure interior frontmatter-field conflict (fences left in the surrounding clean regions) or a pure table/changelog-row conflict — and leaves anything else for manual resolution with the file untouched. Common cases (a `version` bump, a `files:` list extension where the key is in the hunk, unioned changelog rows) still auto-resolve. + ## [4.6.0] - 2026-07-01 ### Added diff --git a/Cargo.lock b/Cargo.lock index de8bf73c..2eaa4724 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1134,7 +1134,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "specsync" -version = "4.6.0" +version = "4.6.1" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 48a62446..4774f237 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "specsync" -version = "4.6.0" +version = "4.6.1" edition = "2024" rust-version = "1.89" description = "Bidirectional spec-to-code validation with schema column checking — 11 languages, single binary" diff --git a/src/ai.rs b/src/ai.rs index 6eaa2b74..89ef07fa 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -1183,8 +1183,22 @@ mod tests { // ── resolve_ai_provider ──────────────────────────────────────── + // `SPECSYNC_AI_COMMAND` is process-global, and `resolve_ai_provider` reads it + // ABOVE `config.ai_command`. Any two tests touching that var must not run + // concurrently, or one leaks into the other (a real flake seen on CI). Serialize + // them through this lock; recover from poisoning so one failing test doesn't + // cascade. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn resolve_with_ai_command_in_config() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Clear any value a sibling test may have left set, so the config-tier path + // is what actually gets exercised here. + // SAFETY: guarded by ENV_LOCK — no other test mutates env concurrently. + unsafe { + std::env::remove_var("SPECSYNC_AI_COMMAND"); + } let mut config = SpecSyncConfig::default(); config.ai_command = Some("my-custom-ai".to_string()); let result = resolve_ai_provider(&config, None).unwrap(); @@ -1196,8 +1210,9 @@ mod tests { #[test] fn resolve_with_env_var() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let config = SpecSyncConfig::default(); - // SAFETY: single-threaded test — no concurrent env access + // SAFETY: guarded by ENV_LOCK — no other test mutates env concurrently. unsafe { std::env::set_var("SPECSYNC_AI_COMMAND", "env-ai-tool"); }