Skip to content

Commit 2b6a5f5

Browse files
fix: harden agent mode safety checks
1 parent 066afe0 commit 2b6a5f5

4 files changed

Lines changed: 509 additions & 13 deletions

File tree

src/agent_discovery.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,16 @@ const RESOURCE_SPECS: &[ResourceSpec] = &[
313313
related: &["simulations", "agents", "personas", "test-sets"],
314314
workflows: &[WorkflowSpec {
315315
name: "Launch a run",
316-
argv: &["runs", "launch"],
316+
argv: &[
317+
"runs",
318+
"launch",
319+
"--agent-id",
320+
"<agent_id>",
321+
"--persona-id",
322+
"<persona_id>",
323+
"--test-set-id",
324+
"<test_set_id>",
325+
],
317326
}],
318327
pitfalls: &["Launch returns a run; use watch or get before assuming outputs are ready."],
319328
},

src/commands/agent.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ fn skills_command(command: SkillCommands, ctx: &OutputContext) -> Result<()> {
242242
"--dest",
243243
"<path>",
244244
]),
245-
true,
245+
false,
246246
));
247247
}
248248
emit_one_with_warnings_and_actions(

src/skills.rs

Lines changed: 97 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ pub fn list(source: Option<&str>, dest: Option<&Path>) -> Result<SkillList> {
4040
};
4141
reject_remote_source(source)?;
4242
let source_path = PathBuf::from(source);
43-
let root = skills_root(&source_path);
43+
let root = skills_root(&source_path)?;
4444
let mut skills = Vec::new();
4545

4646
if root.exists() {
@@ -76,15 +76,17 @@ pub fn list(source: Option<&str>, dest: Option<&Path>) -> Result<SkillList> {
7676

7777
pub fn install(source: &str, dest: &Path, id: &str, force: bool) -> Result<SkillInstall> {
7878
reject_remote_source(source)?;
79+
let id_path = skill_id_path(id)?;
7980
let source_path = PathBuf::from(source);
80-
let root = skills_root(&source_path);
81-
let source_skill = root.join(id);
81+
let root = skills_root(&source_path)?;
82+
let source_skill = root.join(&id_path);
8283
let skill_file = source_skill.join("SKILL.md");
8384
if !skill_file.exists() {
8485
anyhow::bail!("Skill not found in source: {id}");
8586
}
87+
ensure_inside_root(&root, &source_skill, id)?;
8688

87-
let target = dest.join(id);
89+
let target = dest.join(&id_path);
8890
if target.exists() {
8991
if force {
9092
fs::remove_dir_all(&target)?;
@@ -103,15 +105,71 @@ pub fn install(source: &str, dest: &Path, id: &str, force: bool) -> Result<Skill
103105
})
104106
}
105107

106-
fn skills_root(source: &Path) -> PathBuf {
108+
fn skill_id_path(id: &str) -> Result<PathBuf> {
109+
let parts = id.split('/').collect::<Vec<_>>();
110+
if parts.len() != 2
111+
|| parts
112+
.iter()
113+
.any(|part| part.is_empty() || *part == "." || *part == ".." || part.contains('\\'))
114+
{
115+
anyhow::bail!("Invalid skill id: {id}. Expected <namespace>/<skill>.");
116+
}
117+
118+
Ok(PathBuf::from(parts[0]).join(parts[1]))
119+
}
120+
121+
fn skills_root(source: &Path) -> Result<PathBuf> {
122+
reject_path_symlink(source)?;
107123
let nested = source.join("skills");
108-
if nested.exists() {
109-
nested
110-
} else {
111-
source.to_path_buf()
124+
125+
match fs::symlink_metadata(&nested) {
126+
Ok(metadata) => {
127+
if metadata.file_type().is_symlink() {
128+
anyhow::bail!(
129+
"Skill source contains unsupported symlink: {}",
130+
nested.display()
131+
);
132+
}
133+
if metadata.is_dir() {
134+
Ok(nested)
135+
} else {
136+
Ok(source.to_path_buf())
137+
}
138+
}
139+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(source.to_path_buf()),
140+
Err(error) => Err(error).with_context(|| format!("failed to inspect {}", nested.display())),
141+
}
142+
}
143+
144+
fn reject_path_symlink(path: &Path) -> Result<()> {
145+
match fs::symlink_metadata(path) {
146+
Ok(metadata) if metadata.file_type().is_symlink() => {
147+
anyhow::bail!(
148+
"Skill source contains unsupported symlink: {}",
149+
path.display()
150+
);
151+
}
152+
Ok(_) => Ok(()),
153+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
154+
Err(error) => Err(error).with_context(|| format!("failed to inspect {}", path.display())),
112155
}
113156
}
114157

158+
fn ensure_inside_root(root: &Path, source_skill: &Path, id: &str) -> Result<()> {
159+
let root = root
160+
.canonicalize()
161+
.with_context(|| format!("failed to resolve {}", root.display()))?;
162+
let source_skill = source_skill
163+
.canonicalize()
164+
.with_context(|| format!("failed to resolve {}", source_skill.display()))?;
165+
166+
if !source_skill.starts_with(root) {
167+
anyhow::bail!("Skill source escapes skills root: {id}");
168+
}
169+
170+
Ok(())
171+
}
172+
115173
fn read_dirs(path: &Path) -> Result<Vec<PathBuf>> {
116174
let mut dirs = Vec::new();
117175
for entry in fs::read_dir(path)? {
@@ -154,16 +212,44 @@ fn read_description(path: &Path) -> Result<Option<String>> {
154212
}
155213

156214
fn copy_dir(source: &Path, target: &Path) -> Result<()> {
215+
let metadata = fs::symlink_metadata(source)
216+
.with_context(|| format!("failed to inspect {}", source.display()))?;
217+
let file_type = metadata.file_type();
218+
if file_type.is_symlink() {
219+
anyhow::bail!(
220+
"Skill source contains unsupported symlink: {}",
221+
source.display()
222+
);
223+
}
224+
if !file_type.is_dir() {
225+
anyhow::bail!(
226+
"Skill source contains unsupported file type: {}",
227+
source.display()
228+
);
229+
}
230+
157231
fs::create_dir_all(target)?;
158232
for entry in fs::read_dir(source)? {
159233
let entry = entry?;
160234
let source_path = entry.path();
161235
let target_path = target.join(entry.file_name());
162-
if entry.file_type()?.is_dir() {
236+
let file_type = entry.file_type()?;
237+
if file_type.is_symlink() {
238+
anyhow::bail!(
239+
"Skill source contains unsupported symlink: {}",
240+
source_path.display()
241+
);
242+
}
243+
if file_type.is_dir() {
163244
copy_dir(&source_path, &target_path)?;
164-
} else {
245+
} else if file_type.is_file() {
165246
fs::copy(&source_path, &target_path)
166247
.with_context(|| format!("failed to copy {}", source_path.display()))?;
248+
} else {
249+
anyhow::bail!(
250+
"Skill source contains unsupported file type: {}",
251+
source_path.display()
252+
);
167253
}
168254
}
169255
Ok(())

0 commit comments

Comments
 (0)