Fix: reject files: paths that escape the project root (High: info disclosure)#298
Conversation
…ure) A spec's `files:` entries were resolved with a bare `root.join(file)` and then read/extracted with no containment check, so an absolute path (`/etc/passwd`), a `..` traversal, or an in-repo symlink pointing outside the project all passed validation: the out-of-root file counted as covered LOC and its exported identifiers leaked into `check`/coverage output and PR comments. A hostile repo could exfiltrate identifiers from arbitrary readable host files in CI — the same "committed repo runs on the victim" threat model as the aiCommand RCE. Source files a spec validates must live inside the project root. New `source_within_root` canonicalizes root and the resolved path (resolving `..` and symlinks) and requires the file to be inside root. The files-exist check now reports out-of-root entries as an error, and export extraction skips them so no out-of-root identifier is ever read. Non-existent paths are unaffected (the existence check still handles those); in-root relative paths and in-root symlinks still work (verified: the repo's own 60 specs pass at 100%). Tests: an absolute out-of-root path and a `..` escape are both rejected with an "outside the project root" error, and their exports do not leak into warnings. Found by dogfooding the CLI end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
There was a problem hiding this comment.
Code Review
This pull request introduces path validation checks to ensure that source files referenced in specifications resolve within the project root, preventing path traversal and information disclosure vulnerabilities. It adds a helper function source_within_root and corresponding unit tests. The reviewer pointed out a potential security bypass in the helper function's fallback behavior when canonicalization fails, suggesting a fail-closed approach by checking if the file exists instead of unconditionally returning true.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| match (root.canonicalize(), full.canonicalize()) { | ||
| (Ok(canon_root), Ok(canon_full)) => canon_full.starts_with(&canon_root), | ||
| _ => true, | ||
| } |
There was a problem hiding this comment.
The current fallback behavior of returning true when canonicalization fails is a potential security bypass (fail-open). If an attacker can trigger a canonicalization failure on either root or full (for example, if root is unreadable/deleted, or if there is a temporary filesystem/I/O error, or if a path contains special characters that fail canonicalization but can still be read), the function will return true, allowing the path containment check to be bypassed and potentially leaking files.
To make this check fail-secure (fail-closed) while still allowing the existence check to handle non-existent files with a better error message, we can return !full.exists() on failure. This ensures that if the file actually exists but cannot be verified to be within the root, it is safely rejected.
| match (root.canonicalize(), full.canonicalize()) { | |
| (Ok(canon_root), Ok(canon_full)) => canon_full.starts_with(&canon_root), | |
| _ => true, | |
| } | |
| match (root.canonicalize(), full.canonicalize()) { | |
| (Ok(canon_root), Ok(canon_full)) => canon_full.starts_with(&canon_root), | |
| _ => !full.exists(), | |
| } |
There was a problem hiding this comment.
✅ Corvin says...
_
<(^\ .oO(Caw! ^v^)
|/(\
\(\\
" "\\
"Caw! Your code sparkles like a dropped french fry."
CI Summary
| Check | Status |
|---|---|
| Validate action.yml | ✅ Passed |
| Dependency Audit | ✅ Passed |
| Code Coverage | ✅ Passed |
| Format Check | ✅ Passed |
| Docs Site | ✅ Passed |
| Spec Validation | ✅ Passed |
| Tests (build, test, clippy) | ✅ Passed |
| VS Code Extension | ✅ Passed |
📋 Spec Validation Details
✅ SpecSync: Passed
| Metric | Value |
|---|---|
| Specs checked | 60 |
| Passed | 60 |
| Errors | 0 |
| Warnings | 0 |
| File coverage | 100% (76/76) |
| LOC coverage | 100% (34846/34846) |
Generated by specsync · Run specsync check --format github to reproduce
Powered by corvid-pet
…st check Review found the first fix guarded only `validate_spec` (the `check` path), so the identical out-of-root identifier leak stayed live through sibling commands that read `fm.files` and extract exports: - `score` (and the MCP `score` tool) surfaced out-of-root identifiers in suggestions — empirically reproduced; - `check --fix` read AND persisted them into the spec's Public API table; - `diff` surfaced them as new_exports; `new` scaffolded them in. Promote `source_within_root` to a shared `pub(crate)` guard and apply it at every export-extraction site (scoring.rs, commands/check.rs ×2, commands/diff.rs, commands/new.rs), closing the class rather than one instance. `compute_coverage` needs no guard — it enumerates real files under source_dirs and uses `files:` only as a membership set, so an out-of-root path contributes zero LOC. Documents the new `source_within_root` export in the validator spec (v4 + changelog) per the repo's own export-drift rule. Test: `score` on a spec listing an absolute out-of-root file no longer leaks its identifiers into suggestions. Known lower-severity follow-up (existence-oracle only, reveals a bool not identifiers): depends_on/`.exists()` probes in validator/report/stale/scoring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
There was a problem hiding this comment.
✅ Corvin says...
_
<(^\ .oO(Caw! ^v^)
|/(\
\(\\
" "\\
"Caw! Your code sparkles like a dropped french fry."
CI Summary
| Check | Status |
|---|---|
| Validate action.yml | ✅ Passed |
| Dependency Audit | ✅ Passed |
| Code Coverage | ✅ Passed |
| Format Check | ✅ Passed |
| Docs Site | ✅ Passed |
| Spec Validation | ✅ Passed |
| Tests (build, test, clippy) | ✅ Passed |
| VS Code Extension | ✅ Passed |
📋 Spec Validation Details
✅ SpecSync: Passed
| Metric | Value |
|---|---|
| Specs checked | 60 |
| Passed | 60 |
| Errors | 0 |
| Warnings | 0 |
| File coverage | 100% (76/76) |
| LOC coverage | 100% (34910/34910) |
Generated by specsync · Run specsync check --format github to reproduce
Powered by corvid-pet
Re-review confirmed the export-identifier leak is closed at all extraction sites, but flagged two remaining out-of-root CONTENT reads that my own "every content read site" comment implied were covered: - `deps` (`check_undeclared_imports`) read out-of-root `files:` and extracted imports (a near-boolean oracle: only surfaces imports matching a known module). - `check` AI-regen (`regenerate_spec_with_ai`) read the FULL content of out-of-root `files:` into the AI prompt — a bigger disclosure, though gated behind the user having set SPECSYNC_AI_COMMAND + explicit regen. Both now skip entries failing `source_within_root`, so no `files:` content read in the crate touches a path outside the project root. Makes the doc comment true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
There was a problem hiding this comment.
✅ Corvin says...
_
<(^\ .oO(Caw! ^v^)
|/(\
\(\\
" "\\
"Caw! Your code sparkles like a dropped french fry."
CI Summary
| Check | Status |
|---|---|
| Validate action.yml | ✅ Passed |
| Dependency Audit | ✅ Passed |
| Code Coverage | ✅ Passed |
| Format Check | ✅ Passed |
| Docs Site | ✅ Passed |
| Spec Validation | ✅ Passed |
| Tests (build, test, clippy) | ✅ Passed |
| VS Code Extension | ✅ Passed |
📋 Spec Validation Details
✅ SpecSync: Passed
| Metric | Value |
|---|---|
| Specs checked | 60 |
| Passed | 60 |
| Errors | 0 |
| Warnings | 0 |
| File coverage | 100% (76/76) |
| LOC coverage | 100% (34920/34920) |
Generated by specsync · Run specsync check --format github to reproduce
Powered by corvid-pet
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m
* chore: release v4.6.1 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Fixes a High info-disclosure bug found while dogfooding: a spec's
files:entries had no path containment, so an entry resolving outside the project root (absolute path,.., or an in-repo symlink pointing out) was validated — the out-of-root file counted as covered and its exported identifiers leaked intocheck/score/diffoutput, PR comments, and the MCP tools.Fix (closes the whole class)
source_within_root(root, file)(sharedpub(crate)guard) canonicalizes both the root and the resolved path (resolving..and symlinks) and requires the file to sit inside the canonical root.files:entry's content and extracts identifiers:validate_spec(check) — reports out-of-root entries as an error and skips extractionscore(and the MCPscoretool)check --fix(both export-collection loops — it persists identifiers into the spec)diffnewcompute_coverageneeds no guard: it enumerates real files undersource_dirsand usesfiles:only as a membership set, so an out-of-root path contributes zero LOC (verified: a 20k-line out-of-root file → <100 LOC).Why class-wide
The first version guarded only
validate_spec; review then reproduced the identical leak throughspecsync score. Rather than patch one site, the guard is now a single shared helper applied at all extraction sites.Verified
/etc/passwdis rejected, the spec fails (not a silent pass), nopasswdidentifiers leak...escapes rejected with no export leak;scoreon an out-of-root file no longer leaks identifiers into suggestions.Known lower-severity follow-up (not in scope)
depends_on.exists()probes (validator/report/stale/scoring) are an existence-oracle only — they reveal a boolean, not identifiers. Happy to extend containment there separately.🤖 Generated with Claude Code