Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions crates/hk-core/src/sanitize.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::HkError;
use anyhow::{Result, bail};
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -106,6 +107,60 @@ pub fn is_windows_abs_path(s: &str) -> bool {
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}

/// Resolve `~` and validate a user-supplied custom config path.
///
/// Custom config paths are user-typed pointers to files/directories the user
/// wants HarnessKit to track. They are not subject to a home/project gate —
/// the OS already gates whether HK can read those paths via filesystem
/// permissions, and forcing HPC users (or anyone working outside `$HOME`) to
/// register a project just to track a single config file is unnecessary
/// friction.
///
/// Used by both web (`hk-web::handlers::agents`) and desktop
/// (`hk-desktop::commands::settings`) so the two surfaces stay in lockstep.
///
/// Returns `HkError` with kind-specific failure modes:
/// - `PathNotAllowed`: path contains `..` (literal traversal attempt)
/// - `Validation`: path does not exist, or equals home dir itself
pub fn resolve_and_validate_config_path(path: &str) -> Result<String, HkError> {
let resolved = if path == "~" {
dirs::home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string())
} else if let Some(rest) = path.strip_prefix("~/") {
dirs::home_dir()
.map(|h| h.join(rest).to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string())
} else {
path.to_string()
};
if resolved.contains("..") {
return Err(HkError::PathNotAllowed(
"Config paths cannot contain '..' components".into(),
));
}
let resolved_path = Path::new(&resolved);
if !resolved_path.exists() {
return Err(HkError::Validation(
"Path does not exist on disk. Create the file or directory before adding it.".into(),
));
}
// Compare canonical paths so trailing slashes, symlinks, and other
// surface variants (e.g. `/Users/zoe/` vs `/Users/zoe`, or a symlink
// pointing to home) all reject as "home itself".
let home = dirs::home_dir().unwrap_or_default();
if let (Ok(rp), Ok(hp)) = (
std::fs::canonicalize(resolved_path),
std::fs::canonicalize(&home),
) && rp == hp
{
return Err(HkError::Validation(
"Cannot use home directory itself as a config path".into(),
));
}
Ok(resolved)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -221,4 +276,67 @@ mod tests {
assert!(!is_windows_abs_path("~/foo"));
assert!(!is_windows_abs_path("C:")); // too short
}

/// D-spirit regression: a custom config path under a directory that is
/// neither home nor a registered project should be accepted. The OS
/// gates whether HK can later read the file; HK does not impose its
/// own home/project boundary on user-typed config paths.
#[test]
fn config_path_accepts_path_outside_home() {
let tmp = tempfile::tempdir().unwrap();
let outside = tmp.path().join("custom_config.json");
std::fs::write(&outside, "{}").unwrap();

let result = resolve_and_validate_config_path(&outside.to_string_lossy());
assert!(result.is_ok(), "expected ok, got: {result:?}");
}

#[test]
fn config_path_rejects_dotdot() {
let tmp = tempfile::tempdir().unwrap();
let path = format!("{}/foo/../bar.json", tmp.path().display());
let result = resolve_and_validate_config_path(&path);
assert!(matches!(result, Err(HkError::PathNotAllowed(_))));
}

#[test]
fn config_path_rejects_nonexistent() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("does-not-exist.json");
let result = resolve_and_validate_config_path(&path.to_string_lossy());
assert!(matches!(result, Err(HkError::Validation(_))));
}

/// Bare "~" should expand to home and be rejected as "home itself" — not
/// treated as a literal "~" path which then fails the existence check.
#[test]
fn config_path_bare_tilde_expands_to_home_then_rejected() {
let result = resolve_and_validate_config_path("~");
match result {
Err(HkError::Validation(msg)) => assert!(
msg.contains("home directory itself"),
"expected home-itself error, got: {msg}"
),
other => panic!("expected Validation('home directory itself'), got: {other:?}"),
}
}

/// Home with trailing slash (or any surface variant pointing at home)
/// should be caught by the canonicalize-based comparison.
#[test]
fn config_path_rejects_home_with_trailing_slash() {
let home = match dirs::home_dir() {
Some(h) => h,
None => return, // skip on environments without a home dir
};
let with_trailing = format!("{}/", home.to_string_lossy());
let result = resolve_and_validate_config_path(&with_trailing);
match result {
Err(HkError::Validation(msg)) => assert!(
msg.contains("home directory itself"),
"expected home-itself error, got: {msg}"
),
other => panic!("expected Validation('home directory itself'), got: {other:?}"),
}
}
}
26 changes: 7 additions & 19 deletions crates/hk-desktop/src/commands/extensions.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::AppState;
use super::helpers::{FileEntry, is_path_within_allowed_dirs, list_dir_entries};
use super::helpers::{FileEntry, list_dir_entries};
use hk_core::service::ExtensionContent;
use hk_core::{HkError, manager, models::*, scanner, service};
use tauri::{Emitter, State};
Expand Down Expand Up @@ -51,31 +51,24 @@ pub fn uninstall_cli_binary(binary_path: String) -> Result<(), HkError> {

/// List files in a skill directory as a shallow tree (2 levels deep).
#[tauri::command]
pub fn list_skill_files(state: State<AppState>, path: String) -> Result<Vec<FileEntry>, HkError> {
pub fn list_skill_files(
_state: State<AppState>,
path: String,
) -> Result<Vec<FileEntry>, HkError> {
let root = std::path::Path::new(&path);
if !root.is_dir() {
return Err(HkError::Validation("Path is not a directory".into()));
}
if !is_path_within_allowed_dirs(root, &state)? {
return Err(HkError::PathNotAllowed(
"Path is not within a known agent or project directory".into(),
));
}
list_dir_entries(root, 0)
}

/// Open a file or directory in the system's default application.
#[tauri::command]
pub fn open_in_system(state: State<AppState>, path: String) -> Result<(), HkError> {
pub fn open_in_system(_state: State<AppState>, path: String) -> Result<(), HkError> {
let file_path = std::path::Path::new(&path);
if !file_path.exists() {
return Err(HkError::NotFound("Path does not exist".into()));
}
if !is_path_within_allowed_dirs(file_path, &state)? {
return Err(HkError::PathNotAllowed(
"Path is not within a known agent or project directory".into(),
));
}
if file_path.is_file() {
let allowed_extensions = [
"md", "txt", "json", "toml", "yaml", "yml", "xml", "js", "ts", "py", "rs", "go", "css",
Expand Down Expand Up @@ -115,16 +108,11 @@ pub fn open_in_system(state: State<AppState>, path: String) -> Result<(), HkErro

/// Reveal a file or directory in the system file manager (Finder / Explorer).
#[tauri::command]
pub fn reveal_in_file_manager(state: State<AppState>, path: String) -> Result<(), HkError> {
pub fn reveal_in_file_manager(_state: State<AppState>, path: String) -> Result<(), HkError> {
let file_path = std::path::Path::new(&path);
if !file_path.exists() {
return Err(HkError::NotFound("Path does not exist".into()));
}
if !is_path_within_allowed_dirs(file_path, &state)? {
return Err(HkError::PathNotAllowed(
"Path is not within a known agent or project directory".into(),
));
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
Expand Down
42 changes: 0 additions & 42 deletions crates/hk-desktop/src/commands/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,47 +1,5 @@
use hk_core::HkError;

/// Validate that a path is within a known agent directory, registered project, or the app data dir.
pub(super) fn is_path_within_allowed_dirs(
path: &std::path::Path,
state: &super::AppState,
) -> Result<bool, HkError> {
let canonical = path.canonicalize()?;
let adapters = &*state.adapters;
let store = state.store.lock();
let projects = store.list_projects().unwrap_or_default();
let custom_paths = store.list_all_custom_config_paths().unwrap_or_default();

let allowed = adapters.iter().any(|a| {
a.base_dir()
.canonicalize()
.is_ok_and(|d| canonical.starts_with(&d))
|| a.skill_dirs()
.iter()
.any(|sd| sd.canonicalize().is_ok_and(|d| canonical.starts_with(&d)))
|| a.plugin_dirs()
.iter()
.any(|pd| pd.canonicalize().is_ok_and(|d| canonical.starts_with(&d)))
|| a.mcp_config_path()
.canonicalize()
.is_ok_and(|d| canonical == d)
|| a.global_settings_files()
.iter()
.any(|f| f.canonicalize().is_ok_and(|d| canonical == d))
}) || projects.iter().any(|p| {
std::path::Path::new(&p.path)
.canonicalize()
.is_ok_and(|d| canonical.starts_with(&d))
}) || custom_paths.iter().any(|p| {
std::path::Path::new(p)
.canonicalize()
.is_ok_and(|d| canonical.starts_with(&d))
}) || dirs::home_dir()
.map(|h| h.join(".harnesskit"))
.and_then(|d| d.canonicalize().ok())
.is_some_and(|d| canonical.starts_with(&d));
Ok(allowed)
}

#[derive(serde::Serialize)]
pub struct FileEntry {
pub name: String,
Expand Down
99 changes: 5 additions & 94 deletions crates/hk-desktop/src/commands/settings.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use super::AppState;
use super::helpers::is_path_within_allowed_dirs;
use hk_core::{HkError, models::*};
use tauri::State;

Expand Down Expand Up @@ -104,7 +103,7 @@ pub fn toggle_by_pack(

#[tauri::command]
pub fn read_config_file_preview(
state: State<AppState>,
_state: State<AppState>,
path: String,
max_lines: Option<usize>,
) -> Result<String, HkError> {
Expand All @@ -113,12 +112,8 @@ pub fn read_config_file_preview(
return Err(HkError::NotFound("File not found".into()));
}

if !is_path_within_allowed_dirs(file_path, &state)? {
return Err(HkError::PathNotAllowed(
"Path is not within a known agent or project directory".into(),
));
}

// OS gates whether the user can read this file; HK does not impose its
// own home/project boundary on read-only previews of user-typed paths.
if file_path.is_dir() {
return Ok(render_dir_tree(file_path));
}
Expand Down Expand Up @@ -226,33 +221,7 @@ pub fn add_custom_config_path(
category: String,
target_scope: ConfigScope,
) -> Result<i64, HkError> {
// Resolve ~ to home directory
let resolved = if path.starts_with("~/") {
dirs::home_dir()
.map(|h| h.join(&path[2..]).to_string_lossy().to_string())
.unwrap_or(path.clone())
} else {
path
};
// Reject paths with ".." to prevent traversal bypass (e.g., ~/../../etc/passwd)
if resolved.contains("..") {
return Err(HkError::PathNotAllowed(
"Config paths cannot contain '..' components".into(),
));
}
let resolved_path = std::path::Path::new(&resolved);
let home = dirs::home_dir()
.ok_or_else(|| HkError::Internal("Cannot determine home directory".into()))?;
if !resolved_path.starts_with(&home) {
return Err(HkError::PathNotAllowed(
"Custom config paths must be within your home directory".into(),
));
}
if resolved_path == home {
return Err(HkError::Validation(
"Cannot use home directory itself as a config path".into(),
));
}
let resolved = hk_core::sanitize::resolve_and_validate_config_path(&path)?;
let scope_json = serde_json::to_string(&target_scope).ok();
let store = state.store.lock();
store.add_custom_config_path(&agent, &resolved, &label, &category, scope_json.as_deref())
Expand All @@ -266,31 +235,7 @@ pub fn update_custom_config_path(
label: String,
category: String,
) -> Result<(), HkError> {
let resolved = if path.starts_with("~/") {
dirs::home_dir()
.map(|h| h.join(&path[2..]).to_string_lossy().to_string())
.unwrap_or(path.clone())
} else {
path
};
if resolved.contains("..") {
return Err(HkError::PathNotAllowed(
"Config paths cannot contain '..' components".into(),
));
}
let resolved_path = std::path::Path::new(&resolved);
let home = dirs::home_dir()
.ok_or_else(|| HkError::Internal("Cannot determine home directory".into()))?;
if !resolved_path.starts_with(&home) {
return Err(HkError::PathNotAllowed(
"Custom config paths must be within your home directory".into(),
));
}
if resolved_path == home {
return Err(HkError::Validation(
"Cannot use home directory itself as a config path".into(),
));
}
let resolved = hk_core::sanitize::resolve_and_validate_config_path(&path)?;
let store = state.store.lock();
store.update_custom_config_path(id, &resolved, &label, &category)
}
Expand All @@ -303,41 +248,7 @@ pub fn remove_custom_config_path(state: State<AppState>, id: i64) -> Result<(),

#[cfg(test)]
mod tests {
use super::super::AppState;
use super::*;
use hk_core::store::Store;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::TempDir;

fn test_state() -> (AppState, TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&dir.path().join("test.db")).unwrap();
(
AppState {
store: Arc::new(Mutex::new(store)),
adapters: Arc::new(hk_core::adapter::all_adapters()),
pending_clones: Arc::new(Mutex::new(HashMap::new())),
},
dir,
)
}

#[test]
fn test_custom_paths_are_allowed_for_preview_and_open() {
let (state, dir) = test_state();
let custom_dir = dir.path().join("custom");
std::fs::create_dir_all(&custom_dir).unwrap();

state
.store
.lock()
.add_custom_config_path("claude", &custom_dir.to_string_lossy(), "", "settings", None)
.unwrap();

assert!(is_path_within_allowed_dirs(&custom_dir, &state).unwrap());
}

#[test]
fn test_render_dir_tree_truncates_large_directories() {
Expand Down
Loading
Loading