From 9e4d6c98afb32d7333f52a0d3c26f6de91f3bd0d Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 2 Jul 2026 00:50:40 -0600 Subject: [PATCH 1/3] chore: release v4.6.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch release rolling up the three post-4.6.0 correctness/security fixes: - Security: aiCommand is honored only from SPECSYNC_AI_COMMAND (malicious-repo RCE closed) — #294 - Fixed: non-UTF-8 source files no longer pass validation silently — #293 - Fixed: incremental check re-validates on schema/config change — #295 Bumps Cargo.toml + Cargo.lock and adds the dated CHANGELOG section. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m --- CHANGELOG.md | 11 +++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7895ad2a..70ef95ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ 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. + +### 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.) + ## [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" From 29a24ff55cd94a6c170dc8a12217f5e5479b1ba2 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 2 Jul 2026 14:05:50 -0600 Subject: [PATCH 2/3] Update: expand 4.6.1 changelog to cover all five fixes (#297, #298) The release now includes the two fixes merged after this branch was cut: #298 (files: path-escape / info disclosure, Security) and #297 (merge data-loss, Fixed). Merged main in so the release tag ships the actual code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ef95ff..052d1f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 From 7788141ac22993a53d6dea535939b2e194c245bc Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 2 Jul 2026 14:14:06 -0600 Subject: [PATCH 3/3] Fix: flaky ai_command test race on SPECSYNC_AI_COMMAND env var `resolve_ai_provider` reads the process-global `SPECSYNC_AI_COMMAND` env var above the config `ai_command` tier. Two unit tests exercised those two tiers: `resolve_with_env_var` set the env var while `resolve_with_ai_command_in_config` assumed it unset. Env vars are shared across test threads, so when the two ran concurrently the config test could observe the leaked `env-ai-tool` value and fail `assert_eq!("env-ai-tool", "my-custom-ai")`. It passed locally and on main by scheduling luck but failed deterministically often enough to block CI on all three platforms. Serialize every test that touches the var through a shared `ENV_LOCK` mutex (poison-tolerant) and have the config test clear the var before asserting, so the two tiers are exercised in isolation regardless of thread interleaving. Verified: 2 env tests pass 25/25 in isolation and the full 666-test binary passes 6/6 under full parallelism. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m --- src/ai.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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"); }