Skip to content

Commit dc9b132

Browse files
Mosaleh-AKBclaude
andcommitted
feat: claw parity batch — memory/compact/rules/pricing/hooks/perms
9 of 17 plan tasks completed in a single session. 14 new tests added; full workspace runs 527 of 528 (the 1 failure is pre-existing on upstream main: hooks::tests::malformed_nonempty_hook_output_reports_explicit_diagnostic_with_previews). T1.2 /compact + post-compact CLAUDE.md re-attachment (main.rs:5302) T1.3 Auto-memory discovery — reads ~/.claude/projects/<encoded-cwd>/memory/ so memory written by Claude Code flows into claw's system prompt (prompt.rs: discover_instruction_files + memory_dir_for_cwd) T1.4 Pricing tables for Llama, Qwen, Grok, GPT, local Ollama (zero-cost heuristic via colon-tag detection); avoids the silent $75/M sonnet fallback for non-Anthropic models (usage.rs: pricing_for_model) T1.5 Compound bash pipeline splitting + 9 tests. Closes the `cmd1 && rm -rf /` bypass; quoted strings/backslash-escapes respected (bash_validation.rs: split_bash_pipeline, validate_command rewrite) T2.1 Hook events expanded 3 → 10. Stop, StopFailure, UserPromptSubmit, SessionStart, SessionEnd, PostToolBatch, PermissionRequest, InstructionsLoaded now configurable in settings.json. Firing for new events deferred to a follow-up. Backward-compatible: 3-arg RuntimeHookConfig::new() preserved (hooks.rs, config.rs). T2.2 SKILL.md discovery confirmed already-implemented (no-op). T2.3 AGENT.md discovery confirmed already-implemented; subagent context isolation deferred (no-op). T2.4 Path-scoped rules: .claude/rules/*.md and .claw/rules/*.md discovered and injected. Frontmatter `paths:` glob filtering deferred (prompt.rs: discover_instruction_files extension). T2.5 Permission rule wildcard suffix: Bash(npm run *), Bash(git *), WebFetch(https://example.com/*) now match correctly via PermissionRuleMatcher::Prefix. Existing colon-star and Any forms preserved (permissions.rs: parse_rule_matcher). Plus an earlier session patch routing `xai/<model>` prefix through the XAI provider lane (api/providers/mod.rs: metadata_for_model). Plan reference: ~/.claude/plans/model-later-be-able-snug-blanket.md Deferred: T1.1 (bash branch merge), T3.x (worktrees, /batch, Monitor, scheduler), T4.x (ACP daemon, VS Code, JetBrains extensions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e939777 commit dc9b132

8 files changed

Lines changed: 530 additions & 19 deletions

File tree

rust/crates/api/src/providers/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,17 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
193193
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
194194
});
195195
}
196+
// Explicit `xai/<model>` prefix routes to the XAI provider lane. This is
197+
// useful for running a second OpenAI-compatible endpoint (e.g. Groq) in
198+
// parallel with `OPENAI_BASE_URL`, since they have independent env vars.
199+
if canonical.starts_with("xai/") {
200+
return Some(ProviderMetadata {
201+
provider: ProviderKind::Xai,
202+
auth_env: "XAI_API_KEY",
203+
base_url_env: "XAI_BASE_URL",
204+
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
205+
});
206+
}
196207
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
197208
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
198209
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.

rust/crates/runtime/src/bash_validation.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,36 @@ fn classify_git_command(command: &str) -> CommandIntent {
592592
/// Returns the first non-Allow result, or Allow if all validations pass.
593593
#[must_use]
594594
pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) -> ValidationResult {
595+
// T1.5: Split compound pipelines (`&&`, `||`, `;`, `|`, `&`) at top level
596+
// and validate each segment independently. Without this, a malicious
597+
// chain like `ls && rm -rf /` passes because only the first command is
598+
// inspected.
599+
let segments = split_bash_pipeline(command);
600+
if segments.len() <= 1 {
601+
return validate_command_segment(command, mode, workspace);
602+
}
603+
let mut deferred_warn: Option<ValidationResult> = None;
604+
for segment in segments {
605+
match validate_command_segment(segment, mode, workspace) {
606+
ValidationResult::Allow => {}
607+
block @ ValidationResult::Block { .. } => return block,
608+
warn @ ValidationResult::Warn { .. } => {
609+
if deferred_warn.is_none() {
610+
deferred_warn = Some(warn);
611+
}
612+
}
613+
}
614+
}
615+
deferred_warn.unwrap_or(ValidationResult::Allow)
616+
}
617+
618+
/// Validate a single command segment (the original pre-T1.5 implementation,
619+
/// preserved unchanged so single-command inputs behave identically).
620+
fn validate_command_segment(
621+
command: &str,
622+
mode: PermissionMode,
623+
workspace: &Path,
624+
) -> ValidationResult {
595625
// 1. Mode-level validation (includes read-only checks).
596626
let result = validate_mode(command, mode);
597627
if result != ValidationResult::Allow {
@@ -614,6 +644,73 @@ pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) -
614644
validate_paths(command, workspace)
615645
}
616646

647+
/// Split a bash command at top-level chain/pipe operators, ignoring separators
648+
/// that appear inside single quotes, double quotes, backticks, or after a
649+
/// backslash escape. Recognised separators: `&&`, `||`, `;`, `|`, `&`.
650+
/// Returns trimmed, non-empty segments in order.
651+
fn split_bash_pipeline(command: &str) -> Vec<&str> {
652+
let bytes = command.as_bytes();
653+
let mut segments: Vec<&str> = Vec::new();
654+
let mut start: usize = 0;
655+
let mut i: usize = 0;
656+
let mut in_single = false;
657+
let mut in_double = false;
658+
let mut in_backtick = false;
659+
while i < bytes.len() {
660+
let c = bytes[i];
661+
// Backslash escape (outside single quotes, where it is literal)
662+
if c == b'\\' && !in_single && i + 1 < bytes.len() {
663+
i += 2;
664+
continue;
665+
}
666+
if !in_double && !in_backtick && c == b'\'' {
667+
in_single = !in_single;
668+
i += 1;
669+
continue;
670+
}
671+
if !in_single && !in_backtick && c == b'"' {
672+
in_double = !in_double;
673+
i += 1;
674+
continue;
675+
}
676+
if !in_single && !in_double && c == b'`' {
677+
in_backtick = !in_backtick;
678+
i += 1;
679+
continue;
680+
}
681+
if !in_single && !in_double && !in_backtick {
682+
// Two-byte separators take precedence over one-byte.
683+
let two_byte = i + 1 < bytes.len()
684+
&& (bytes[i] == b'&' && bytes[i + 1] == b'&'
685+
|| bytes[i] == b'|' && bytes[i + 1] == b'|');
686+
if two_byte {
687+
let segment = command[start..i].trim();
688+
if !segment.is_empty() {
689+
segments.push(segment);
690+
}
691+
i += 2;
692+
start = i;
693+
continue;
694+
}
695+
if c == b';' || c == b'|' || c == b'&' {
696+
let segment = command[start..i].trim();
697+
if !segment.is_empty() {
698+
segments.push(segment);
699+
}
700+
i += 1;
701+
start = i;
702+
continue;
703+
}
704+
}
705+
i += 1;
706+
}
707+
let last = command[start..].trim();
708+
if !last.is_empty() {
709+
segments.push(last);
710+
}
711+
segments
712+
}
713+
617714
// ---------------------------------------------------------------------------
618715
// Helpers
619716
// ---------------------------------------------------------------------------
@@ -1001,4 +1098,86 @@ mod tests {
10011098
fn extracts_plain_command() {
10021099
assert_eq!(extract_first_command("grep -r pattern ."), "grep");
10031100
}
1101+
1102+
// --- split_bash_pipeline (T1.5) ---
1103+
1104+
#[test]
1105+
fn split_pipeline_single_command() {
1106+
assert_eq!(split_bash_pipeline("ls -la"), vec!["ls -la"]);
1107+
}
1108+
1109+
#[test]
1110+
fn split_pipeline_double_amp() {
1111+
assert_eq!(
1112+
split_bash_pipeline("ls -la && rm -rf /tmp/x"),
1113+
vec!["ls -la", "rm -rf /tmp/x"]
1114+
);
1115+
}
1116+
1117+
#[test]
1118+
fn split_pipeline_double_pipe() {
1119+
assert_eq!(
1120+
split_bash_pipeline("test -f foo || touch foo"),
1121+
vec!["test -f foo", "touch foo"]
1122+
);
1123+
}
1124+
1125+
#[test]
1126+
fn split_pipeline_semicolon_and_pipe() {
1127+
assert_eq!(
1128+
split_bash_pipeline("ls ; cat /etc/hosts | grep host"),
1129+
vec!["ls", "cat /etc/hosts", "grep host"]
1130+
);
1131+
}
1132+
1133+
#[test]
1134+
fn split_pipeline_respects_double_quotes() {
1135+
assert_eq!(
1136+
split_bash_pipeline(r#"echo "a && b" && ls"#),
1137+
vec![r#"echo "a && b""#, "ls"]
1138+
);
1139+
}
1140+
1141+
#[test]
1142+
fn split_pipeline_respects_single_quotes() {
1143+
assert_eq!(
1144+
split_bash_pipeline(r#"echo 'a;b' ; ls"#),
1145+
vec![r#"echo 'a;b'"#, "ls"]
1146+
);
1147+
}
1148+
1149+
#[test]
1150+
fn split_pipeline_respects_backslash_escape() {
1151+
assert_eq!(
1152+
split_bash_pipeline(r#"echo a\&\&b"#),
1153+
vec![r#"echo a\&\&b"#]
1154+
);
1155+
}
1156+
1157+
// --- validate_command compound-bypass closure (T1.5) ---
1158+
1159+
#[test]
1160+
fn validate_command_blocks_destructive_after_safe_in_chain() {
1161+
let workspace = std::env::current_dir().unwrap();
1162+
// Pre-T1.5: this passed because only "ls -la" was inspected.
1163+
assert!(matches!(
1164+
validate_command("ls -la && rm -rf /tmp/x", PermissionMode::ReadOnly, &workspace),
1165+
ValidationResult::Block { .. }
1166+
));
1167+
}
1168+
1169+
#[test]
1170+
fn validate_command_allows_chain_of_safe_commands() {
1171+
let workspace = std::env::current_dir().unwrap();
1172+
assert_eq!(
1173+
validate_command("ls -la && pwd && echo hi", PermissionMode::ReadOnly, &workspace),
1174+
ValidationResult::Allow
1175+
);
1176+
}
1177+
1178+
// Note: I considered a test that `echo "ls && rm -rf /"` should be Allow
1179+
// because the quoted text is not a separate command. The split correctly
1180+
// returns one segment, but `check_destructive` (correctly) scans the
1181+
// whole string for `rm -rf /`-like fork-bomb patterns and blocks anyway.
1182+
// Pre-existing paranoid behavior, not a regression from T1.5.
10041183
}

rust/crates/runtime/src/config.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,25 @@ pub struct ProviderFallbackConfig {
7777
}
7878

7979
/// Hook command lists grouped by lifecycle stage.
80+
///
81+
/// T2.1: Extended from 3 to 10 lifecycle events for Claude Code parity. The
82+
/// 3-arg `new()` constructor is preserved for backward compatibility with
83+
/// existing call sites; the additional events default to empty and can be
84+
/// populated either via settings.json parsing or the dedicated builder
85+
/// methods.
8086
#[derive(Debug, Clone, PartialEq, Eq, Default)]
8187
pub struct RuntimeHookConfig {
8288
pre_tool_use: Vec<String>,
8389
post_tool_use: Vec<String>,
8490
post_tool_use_failure: Vec<String>,
91+
stop: Vec<String>,
92+
stop_failure: Vec<String>,
93+
user_prompt_submit: Vec<String>,
94+
session_start: Vec<String>,
95+
session_end: Vec<String>,
96+
post_tool_batch: Vec<String>,
97+
permission_request: Vec<String>,
98+
instructions_loaded: Vec<String>,
8599
}
86100

87101
/// Raw permission rule lists grouped by allow, deny, and ask behavior.
@@ -575,6 +589,7 @@ impl RuntimeHookConfig {
575589
pre_tool_use,
576590
post_tool_use,
577591
post_tool_use_failure,
592+
..Default::default()
578593
}
579594
}
580595

@@ -602,12 +617,54 @@ impl RuntimeHookConfig {
602617
&mut self.post_tool_use_failure,
603618
other.post_tool_use_failure(),
604619
);
620+
extend_unique(&mut self.stop, other.stop());
621+
extend_unique(&mut self.stop_failure, other.stop_failure());
622+
extend_unique(&mut self.user_prompt_submit, other.user_prompt_submit());
623+
extend_unique(&mut self.session_start, other.session_start());
624+
extend_unique(&mut self.session_end, other.session_end());
625+
extend_unique(&mut self.post_tool_batch, other.post_tool_batch());
626+
extend_unique(&mut self.permission_request, other.permission_request());
627+
extend_unique(&mut self.instructions_loaded, other.instructions_loaded());
605628
}
606629

607630
#[must_use]
608631
pub fn post_tool_use_failure(&self) -> &[String] {
609632
&self.post_tool_use_failure
610633
}
634+
635+
// T2.1: Claude Code parity event accessors.
636+
#[must_use]
637+
pub fn stop(&self) -> &[String] {
638+
&self.stop
639+
}
640+
#[must_use]
641+
pub fn stop_failure(&self) -> &[String] {
642+
&self.stop_failure
643+
}
644+
#[must_use]
645+
pub fn user_prompt_submit(&self) -> &[String] {
646+
&self.user_prompt_submit
647+
}
648+
#[must_use]
649+
pub fn session_start(&self) -> &[String] {
650+
&self.session_start
651+
}
652+
#[must_use]
653+
pub fn session_end(&self) -> &[String] {
654+
&self.session_end
655+
}
656+
#[must_use]
657+
pub fn post_tool_batch(&self) -> &[String] {
658+
&self.post_tool_batch
659+
}
660+
#[must_use]
661+
pub fn permission_request(&self) -> &[String] {
662+
&self.permission_request
663+
}
664+
#[must_use]
665+
pub fn instructions_loaded(&self) -> &[String] {
666+
&self.instructions_loaded
667+
}
611668
}
612669

613670
impl RuntimePermissionRuleConfig {
@@ -767,6 +824,18 @@ fn parse_optional_hooks_config_object(
767824
post_tool_use: optional_string_array(hooks, "PostToolUse", context)?.unwrap_or_default(),
768825
post_tool_use_failure: optional_string_array(hooks, "PostToolUseFailure", context)?
769826
.unwrap_or_default(),
827+
stop: optional_string_array(hooks, "Stop", context)?.unwrap_or_default(),
828+
stop_failure: optional_string_array(hooks, "StopFailure", context)?.unwrap_or_default(),
829+
user_prompt_submit: optional_string_array(hooks, "UserPromptSubmit", context)?
830+
.unwrap_or_default(),
831+
session_start: optional_string_array(hooks, "SessionStart", context)?.unwrap_or_default(),
832+
session_end: optional_string_array(hooks, "SessionEnd", context)?.unwrap_or_default(),
833+
post_tool_batch: optional_string_array(hooks, "PostToolBatch", context)?
834+
.unwrap_or_default(),
835+
permission_request: optional_string_array(hooks, "PermissionRequest", context)?
836+
.unwrap_or_default(),
837+
instructions_loaded: optional_string_array(hooks, "InstructionsLoaded", context)?
838+
.unwrap_or_default(),
770839
})
771840
}
772841

rust/crates/runtime/src/hooks.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ pub enum HookEvent {
2323
PreToolUse,
2424
PostToolUse,
2525
PostToolUseFailure,
26+
// T2.1: Claude Code parity events. Configurable from settings.json now;
27+
// firing wired in stages — see conversation.rs lifecycle for which are
28+
// currently emitted vs reserved for future PRs.
29+
Stop,
30+
StopFailure,
31+
UserPromptSubmit,
32+
SessionStart,
33+
SessionEnd,
34+
PostToolBatch,
35+
PermissionRequest,
36+
InstructionsLoaded,
2637
}
2738

2839
impl HookEvent {
@@ -32,6 +43,14 @@ impl HookEvent {
3243
Self::PreToolUse => "PreToolUse",
3344
Self::PostToolUse => "PostToolUse",
3445
Self::PostToolUseFailure => "PostToolUseFailure",
46+
Self::Stop => "Stop",
47+
Self::StopFailure => "StopFailure",
48+
Self::UserPromptSubmit => "UserPromptSubmit",
49+
Self::SessionStart => "SessionStart",
50+
Self::SessionEnd => "SessionEnd",
51+
Self::PostToolBatch => "PostToolBatch",
52+
Self::PermissionRequest => "PermissionRequest",
53+
Self::InstructionsLoaded => "InstructionsLoaded",
3554
}
3655
}
3756
}

rust/crates/runtime/src/permissions.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,12 @@ fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
395395
if unescaped.is_empty() || unescaped == "*" {
396396
PermissionRuleMatcher::Any
397397
} else if let Some(prefix) = unescaped.strip_suffix(":*") {
398+
// Provider-specific colon-star form, e.g. WebFetch(domain:*).
399+
PermissionRuleMatcher::Prefix(prefix.to_string())
400+
} else if let Some(prefix) = unescaped.strip_suffix('*') {
401+
// T2.5: General trailing-`*` glob — `Bash(npm run *)`, `Bash(git *)`,
402+
// `WebFetch(https://example.com/*)`. Matches any input whose subject
403+
// starts with the literal prefix (everything before the `*`).
398404
PermissionRuleMatcher::Prefix(prefix.to_string())
399405
} else {
400406
PermissionRuleMatcher::Exact(unescaped)

0 commit comments

Comments
 (0)