diff --git a/integrations/opencode/marmot/README.md b/integrations/opencode/marmot/README.md index 911281ef..f581fe69 100644 --- a/integrations/opencode/marmot/README.md +++ b/integrations/opencode/marmot/README.md @@ -104,19 +104,22 @@ newline, then space boundaries and never splits a UTF-8 code point. ## Workdir Picker -On the first message in a new Marmot group, a leading `/` selects -`~/` as the OpenCode working directory if it is a direct child directory of -`$HOME`. +On the first message in a new Marmot group, a leading `/` selects +`~/` as the OpenCode working directory if the canonical target is inside +`$HOME`. Path segments are separated by `/` and each segment must be +ASCII-alphanumeric or `.`, `_`, `-`; empty, `.`, and `..` segments are rejected. Examples: ```text /mdk fix the failing test /mdk +/projects/mdk fix the failing test ``` When the message is only the picker, `wn-opencode` stores the workdir and asks -for the next prompt. +for the next prompt. Symlinks that resolve outside `$HOME` are rejected after +canonicalization. ## Security Notes diff --git a/integrations/opencode/marmot/src/repo_picker.rs b/integrations/opencode/marmot/src/repo_picker.rs index 1225b053..86588bbe 100644 --- a/integrations/opencode/marmot/src/repo_picker.rs +++ b/integrations/opencode/marmot/src/repo_picker.rs @@ -14,18 +14,23 @@ pub(crate) fn parse_repo_picker(text: &str) -> Option<(String, String)> { .find(|(_, c)| c.is_whitespace()) .map(|(index, _)| index) .unwrap_or(rest.len()); - let name = &rest[..end]; - if name.is_empty() || matches!(name, "." | "..") { + let path = &rest[..end]; + if path.is_empty() { return None; } - if !name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) - { - return None; + for segment in path.split('/') { + if segment.is_empty() || matches!(segment, "." | "..") { + return None; + } + if !segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return None; + } } - Some((name.to_owned(), rest[end..].trim_start().to_owned())) + Some((path.to_owned(), rest[end..].trim_start().to_owned())) } pub(crate) async fn resolve_repo(name: &str, home: &Path) -> Result { @@ -36,7 +41,7 @@ pub(crate) async fn resolve_repo(name: &str, home: &Path) -> Result { let home_canonical = tokio::fs::canonicalize(home) .await .map_err(|_| repo_error("Cannot read $HOME."))?; - if canonical.parent() != Some(&home_canonical) { + if canonical == home_canonical || !canonical.starts_with(&home_canonical) { return Err(repo_error(format!( "Repo ~/{name} resolves outside $HOME; refusing." ))); @@ -57,7 +62,7 @@ pub(crate) async fn validate_session_cwd(cwd: &Path, home: &Path) -> Result