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
11 changes: 11 additions & 0 deletions rust/crates/api/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,17 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
});
}
// Explicit `xai/<model>` prefix routes to the XAI provider lane. This is
// useful for running a second OpenAI-compatible endpoint (e.g. Groq) in
// parallel with `OPENAI_BASE_URL`, since they have independent env vars.
if canonical.starts_with("xai/") {
return Some(ProviderMetadata {
provider: ProviderKind::Xai,
auth_env: "XAI_API_KEY",
base_url_env: "XAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
});
}
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
Expand Down
179 changes: 179 additions & 0 deletions rust/crates/runtime/src/bash_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,36 @@ fn classify_git_command(command: &str) -> CommandIntent {
/// Returns the first non-Allow result, or Allow if all validations pass.
#[must_use]
pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) -> ValidationResult {
// T1.5: Split compound pipelines (`&&`, `||`, `;`, `|`, `&`) at top level
// and validate each segment independently. Without this, a malicious
// chain like `ls && rm -rf /` passes because only the first command is
// inspected.
let segments = split_bash_pipeline(command);
if segments.len() <= 1 {
return validate_command_segment(command, mode, workspace);
}
let mut deferred_warn: Option<ValidationResult> = None;
for segment in segments {
match validate_command_segment(segment, mode, workspace) {
ValidationResult::Allow => {}
block @ ValidationResult::Block { .. } => return block,
warn @ ValidationResult::Warn { .. } => {
if deferred_warn.is_none() {
deferred_warn = Some(warn);
}
}
}
}
deferred_warn.unwrap_or(ValidationResult::Allow)
}

/// Validate a single command segment (the original pre-T1.5 implementation,
/// preserved unchanged so single-command inputs behave identically).
fn validate_command_segment(
command: &str,
mode: PermissionMode,
workspace: &Path,
) -> ValidationResult {
// 1. Mode-level validation (includes read-only checks).
let result = validate_mode(command, mode);
if result != ValidationResult::Allow {
Expand All @@ -614,6 +644,73 @@ pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) -
validate_paths(command, workspace)
}

/// Split a bash command at top-level chain/pipe operators, ignoring separators
/// that appear inside single quotes, double quotes, backticks, or after a
/// backslash escape. Recognised separators: `&&`, `||`, `;`, `|`, `&`.
/// Returns trimmed, non-empty segments in order.
fn split_bash_pipeline(command: &str) -> Vec<&str> {
let bytes = command.as_bytes();
let mut segments: Vec<&str> = Vec::new();
let mut start: usize = 0;
let mut i: usize = 0;
let mut in_single = false;
let mut in_double = false;
let mut in_backtick = false;
while i < bytes.len() {
let c = bytes[i];
// Backslash escape (outside single quotes, where it is literal)
if c == b'\\' && !in_single && i + 1 < bytes.len() {
i += 2;
continue;
}
if !in_double && !in_backtick && c == b'\'' {
in_single = !in_single;
i += 1;
continue;
}
if !in_single && !in_backtick && c == b'"' {
in_double = !in_double;
i += 1;
continue;
}
if !in_single && !in_double && c == b'`' {
in_backtick = !in_backtick;
i += 1;
continue;
}
if !in_single && !in_double && !in_backtick {
// Two-byte separators take precedence over one-byte.
let two_byte = i + 1 < bytes.len()
&& (bytes[i] == b'&' && bytes[i + 1] == b'&'
|| bytes[i] == b'|' && bytes[i + 1] == b'|');
if two_byte {
let segment = command[start..i].trim();
if !segment.is_empty() {
segments.push(segment);
}
i += 2;
start = i;
continue;
}
if c == b';' || c == b'|' || c == b'&' {
let segment = command[start..i].trim();
if !segment.is_empty() {
segments.push(segment);
}
i += 1;
start = i;
continue;
}
}
i += 1;
}
let last = command[start..].trim();
if !last.is_empty() {
segments.push(last);
}
segments
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1001,4 +1098,86 @@ mod tests {
fn extracts_plain_command() {
assert_eq!(extract_first_command("grep -r pattern ."), "grep");
}

// --- split_bash_pipeline (T1.5) ---

#[test]
fn split_pipeline_single_command() {
assert_eq!(split_bash_pipeline("ls -la"), vec!["ls -la"]);
}

#[test]
fn split_pipeline_double_amp() {
assert_eq!(
split_bash_pipeline("ls -la && rm -rf /tmp/x"),
vec!["ls -la", "rm -rf /tmp/x"]
);
}

#[test]
fn split_pipeline_double_pipe() {
assert_eq!(
split_bash_pipeline("test -f foo || touch foo"),
vec!["test -f foo", "touch foo"]
);
}

#[test]
fn split_pipeline_semicolon_and_pipe() {
assert_eq!(
split_bash_pipeline("ls ; cat /etc/hosts | grep host"),
vec!["ls", "cat /etc/hosts", "grep host"]
);
}

#[test]
fn split_pipeline_respects_double_quotes() {
assert_eq!(
split_bash_pipeline(r#"echo "a && b" && ls"#),
vec![r#"echo "a && b""#, "ls"]
);
}

#[test]
fn split_pipeline_respects_single_quotes() {
assert_eq!(
split_bash_pipeline(r#"echo 'a;b' ; ls"#),
vec![r#"echo 'a;b'"#, "ls"]
);
}

#[test]
fn split_pipeline_respects_backslash_escape() {
assert_eq!(
split_bash_pipeline(r#"echo a\&\&b"#),
vec![r#"echo a\&\&b"#]
);
}

// --- validate_command compound-bypass closure (T1.5) ---

#[test]
fn validate_command_blocks_destructive_after_safe_in_chain() {
let workspace = std::env::current_dir().unwrap();
// Pre-T1.5: this passed because only "ls -la" was inspected.
assert!(matches!(
validate_command("ls -la && rm -rf /tmp/x", PermissionMode::ReadOnly, &workspace),
ValidationResult::Block { .. }
));
}

#[test]
fn validate_command_allows_chain_of_safe_commands() {
let workspace = std::env::current_dir().unwrap();
assert_eq!(
validate_command("ls -la && pwd && echo hi", PermissionMode::ReadOnly, &workspace),
ValidationResult::Allow
);
}

// Note: I considered a test that `echo "ls && rm -rf /"` should be Allow
// because the quoted text is not a separate command. The split correctly
// returns one segment, but `check_destructive` (correctly) scans the
// whole string for `rm -rf /`-like fork-bomb patterns and blocks anyway.
// Pre-existing paranoid behavior, not a regression from T1.5.
}
69 changes: 69 additions & 0 deletions rust/crates/runtime/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,25 @@ pub struct ProviderFallbackConfig {
}

/// Hook command lists grouped by lifecycle stage.
///
/// T2.1: Extended from 3 to 10 lifecycle events for Claude Code parity. The
/// 3-arg `new()` constructor is preserved for backward compatibility with
/// existing call sites; the additional events default to empty and can be
/// populated either via settings.json parsing or the dedicated builder
/// methods.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeHookConfig {
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
stop: Vec<String>,
stop_failure: Vec<String>,
user_prompt_submit: Vec<String>,
session_start: Vec<String>,
session_end: Vec<String>,
post_tool_batch: Vec<String>,
permission_request: Vec<String>,
instructions_loaded: Vec<String>,
}

/// Raw permission rule lists grouped by allow, deny, and ask behavior.
Expand Down Expand Up @@ -575,6 +589,7 @@ impl RuntimeHookConfig {
pre_tool_use,
post_tool_use,
post_tool_use_failure,
..Default::default()
}
}

Expand Down Expand Up @@ -602,12 +617,54 @@ impl RuntimeHookConfig {
&mut self.post_tool_use_failure,
other.post_tool_use_failure(),
);
extend_unique(&mut self.stop, other.stop());
extend_unique(&mut self.stop_failure, other.stop_failure());
extend_unique(&mut self.user_prompt_submit, other.user_prompt_submit());
extend_unique(&mut self.session_start, other.session_start());
extend_unique(&mut self.session_end, other.session_end());
extend_unique(&mut self.post_tool_batch, other.post_tool_batch());
extend_unique(&mut self.permission_request, other.permission_request());
extend_unique(&mut self.instructions_loaded, other.instructions_loaded());
}

#[must_use]
pub fn post_tool_use_failure(&self) -> &[String] {
&self.post_tool_use_failure
}

// T2.1: Claude Code parity event accessors.
#[must_use]
pub fn stop(&self) -> &[String] {
&self.stop
}
#[must_use]
pub fn stop_failure(&self) -> &[String] {
&self.stop_failure
}
#[must_use]
pub fn user_prompt_submit(&self) -> &[String] {
&self.user_prompt_submit
}
#[must_use]
pub fn session_start(&self) -> &[String] {
&self.session_start
}
#[must_use]
pub fn session_end(&self) -> &[String] {
&self.session_end
}
#[must_use]
pub fn post_tool_batch(&self) -> &[String] {
&self.post_tool_batch
}
#[must_use]
pub fn permission_request(&self) -> &[String] {
&self.permission_request
}
#[must_use]
pub fn instructions_loaded(&self) -> &[String] {
&self.instructions_loaded
}
}

impl RuntimePermissionRuleConfig {
Expand Down Expand Up @@ -767,6 +824,18 @@ fn parse_optional_hooks_config_object(
post_tool_use: optional_string_array(hooks, "PostToolUse", context)?.unwrap_or_default(),
post_tool_use_failure: optional_string_array(hooks, "PostToolUseFailure", context)?
.unwrap_or_default(),
stop: optional_string_array(hooks, "Stop", context)?.unwrap_or_default(),
stop_failure: optional_string_array(hooks, "StopFailure", context)?.unwrap_or_default(),
user_prompt_submit: optional_string_array(hooks, "UserPromptSubmit", context)?
.unwrap_or_default(),
session_start: optional_string_array(hooks, "SessionStart", context)?.unwrap_or_default(),
session_end: optional_string_array(hooks, "SessionEnd", context)?.unwrap_or_default(),
post_tool_batch: optional_string_array(hooks, "PostToolBatch", context)?
.unwrap_or_default(),
permission_request: optional_string_array(hooks, "PermissionRequest", context)?
.unwrap_or_default(),
instructions_loaded: optional_string_array(hooks, "InstructionsLoaded", context)?
.unwrap_or_default(),
})
}

Expand Down
19 changes: 19 additions & 0 deletions rust/crates/runtime/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
// T2.1: Claude Code parity events. Configurable from settings.json now;
// firing wired in stages — see conversation.rs lifecycle for which are
// currently emitted vs reserved for future PRs.
Stop,
StopFailure,
UserPromptSubmit,
SessionStart,
SessionEnd,
PostToolBatch,
PermissionRequest,
InstructionsLoaded,
}

impl HookEvent {
Expand All @@ -32,6 +43,14 @@ impl HookEvent {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PostToolUseFailure => "PostToolUseFailure",
Self::Stop => "Stop",
Self::StopFailure => "StopFailure",
Self::UserPromptSubmit => "UserPromptSubmit",
Self::SessionStart => "SessionStart",
Self::SessionEnd => "SessionEnd",
Self::PostToolBatch => "PostToolBatch",
Self::PermissionRequest => "PermissionRequest",
Self::InstructionsLoaded => "InstructionsLoaded",
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions rust/crates/runtime/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@ fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
if unescaped.is_empty() || unescaped == "*" {
PermissionRuleMatcher::Any
} else if let Some(prefix) = unescaped.strip_suffix(":*") {
// Provider-specific colon-star form, e.g. WebFetch(domain:*).
PermissionRuleMatcher::Prefix(prefix.to_string())
} else if let Some(prefix) = unescaped.strip_suffix('*') {
// T2.5: General trailing-`*` glob — `Bash(npm run *)`, `Bash(git *)`,
// `WebFetch(https://example.com/*)`. Matches any input whose subject
// starts with the literal prefix (everything before the `*`).
PermissionRuleMatcher::Prefix(prefix.to_string())
} else {
PermissionRuleMatcher::Exact(unescaped)
Expand Down
Loading