Skip to content

Commit c55d922

Browse files
committed
fix(security): skip symbolic links during skill updates
Modified `src/skills/update.rs` to ensure symbolic links are not followed when updating a skill from a local source. This prevents potential information disclosure of sensitive host files. Consistent with the hardening previously applied to the installation path in `src/skills/install.rs`. Added regression test in `tests/test_update_security.rs`.
1 parent 0b6536f commit c55d922

3 files changed

Lines changed: 94 additions & 0 deletions

File tree

.agents/journal/sentinel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,8 @@ tool that are known to contain user-provided credentials or sensitive environmen
3131
**Vulnerability:** String-based path checks (like `starts_with('/')`) or platform-specific `Path::is_absolute()` calls failed to detect Windows-style absolute paths (e.g., `C:/...`) when running on Unix systems, leading to potential path traversal vulnerabilities in skill archives.
3232
**Learning:** Rust's `std::path::Path` behavior depends on the host operating system. On Unix, a path starting with a drive letter is considered relative, which allows it to pass simple `is_absolute()` checks and be joined to a base directory, potentially escaping it if not carefully validated.
3333
**Prevention:** Use `Path::components()` to explicitly reject `RootDir`, `Prefix`, and `ParentDir` components. For cross-platform safety on Unix, also manually check for Windows drive letter patterns (e.g., `filename.as_bytes()[1] == b':'`) in untrusted archive entry paths.
34+
35+
## 2025-05-19 - Inconsistent Symlink Handling in Skill Updates
36+
**Vulnerability:** While `src/skills/install.rs` was previously hardened against symlink following, `src/skills/update.rs` used a separate implementation of directory copying (`copy_dir_all`) that still followed symbolic links. This allowed information disclosure during local skill updates even after the installation path was secured.
37+
**Learning:** Security fixes must be applied comprehensively across all code paths performing similar operations. Code duplication (like having multiple recursive copy functions) often leads to inconsistent security postures where one path is hardened but another remains vulnerable.
38+
**Prevention:** Consolidate critical filesystem operations into shared, hardened utilities. If duplication is necessary, use grep to find all instances of risky patterns (like `fs::copy` in a loop) when patching a vulnerability.

src/skills/update.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
195195
for entry in fs::read_dir(src)? {
196196
let entry = entry?;
197197
let ty = entry.file_type()?;
198+
199+
// SECURITY: Skip symbolic links to prevent information disclosure of host files
200+
// during local skill updates.
201+
if ty.is_symlink() {
202+
continue;
203+
}
204+
198205
let src_path = entry.path();
199206
let dst_path = dst.join(entry.file_name());
200207
if ty.is_dir() {

tests/test_update_security.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
use std::fs;
2+
use std::process::Command;
3+
use tempfile::TempDir;
4+
5+
#[cfg(unix)]
6+
use std::os::unix::fs::symlink;
7+
8+
fn agentsync_bin() -> &'static str {
9+
env!("CARGO_BIN_EXE_agentsync")
10+
}
11+
12+
#[test]
13+
#[cfg(unix)]
14+
fn test_update_skill_skips_symlinks() {
15+
let temp_dir = TempDir::new().unwrap();
16+
let project_root = temp_dir.path().join("project");
17+
fs::create_dir_all(&project_root).unwrap();
18+
19+
// 1. Install an initial skill
20+
let initial_src = temp_dir.path().join("initial_src");
21+
fs::create_dir_all(&initial_src).unwrap();
22+
fs::write(
23+
initial_src.join("SKILL.md"),
24+
"---\nname: test-skill\nversion: 1.0.0\n---\n",
25+
)
26+
.unwrap();
27+
28+
let install_out = Command::new(agentsync_bin())
29+
.current_dir(&project_root)
30+
.arg("skill")
31+
.arg("install")
32+
.arg("test-skill")
33+
.arg("--source")
34+
.arg(initial_src.to_str().unwrap())
35+
.output()
36+
.expect("failed to run install");
37+
assert!(install_out.status.success());
38+
39+
// 2. Create a sensitive file outside
40+
let sensitive_file = temp_dir.path().join("sensitive.txt");
41+
fs::write(&sensitive_file, "SECRET_CONTENT").unwrap();
42+
43+
// 3. Create malicious update source
44+
let malicious_src = temp_dir.path().join("malicious_src");
45+
fs::create_dir_all(&malicious_src).unwrap();
46+
fs::write(
47+
malicious_src.join("SKILL.md"),
48+
"---\nname: test-skill\nversion: 1.1.0\n---\n",
49+
)
50+
.unwrap();
51+
52+
// Create a symlink to the sensitive file
53+
symlink(&sensitive_file, malicious_src.join("leak.txt")).unwrap();
54+
55+
// 4. Run update
56+
let update_out = Command::new(agentsync_bin())
57+
.current_dir(&project_root)
58+
.arg("skill")
59+
.arg("update")
60+
.arg("test-skill")
61+
.arg("--source")
62+
.arg(malicious_src.to_str().unwrap())
63+
.output()
64+
.expect("failed to run update");
65+
66+
assert!(
67+
update_out.status.success(),
68+
"Update failed: {}",
69+
String::from_utf8_lossy(&update_out.stderr)
70+
);
71+
72+
// 5. Verify the leak
73+
let leaked_path = project_root.join(".agents/skills/test-skill/leak.txt");
74+
75+
// After the fix, leak.txt should NOT exist because symlinks are skipped.
76+
// Before the fix, leak.txt WILL exist and contain "SECRET_CONTENT" because fs::copy follows symlinks.
77+
assert!(
78+
!leaked_path.exists(),
79+
"SECURITY VULNERABILITY: Symlink was followed during update, leaking content to {}",
80+
leaked_path.display()
81+
);
82+
}

0 commit comments

Comments
 (0)