{"record":{"id":"1ed07eab383426ba","repo":"zeroclaw-labs/zeroclaw","slug":"tool-name-raw-contains-invalid-characters","errorCode":null,"errorMessage":"Tool name '{raw}' contains invalid characters","messagePattern":"Tool name '(.+?)' contains invalid characters","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"crates/zeroclaw-runtime/src/security/estop.rs","lineNumber":270,"sourceCode":"    let expanded = shellexpand::tilde(state_file).into_owned();\n    let path = PathBuf::from(expanded);\n    if path.is_absolute() {\n        path\n    } else {\n        config_dir.join(path)\n    }\n}\n\nfn normalize_tool_name(raw: &str) -> Result<String> {\n    let value = raw.trim().to_ascii_lowercase();\n    if value.is_empty() {\n        anyhow::bail!(\"Tool name must not be empty\");\n    }\n    if !value\n        .chars()\n        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')\n    {\n        anyhow::bail!(\"Tool name '{raw}' contains invalid characters\");\n    }\n    Ok(value)\n}\n\nfn dedup_sort(values: &[String]) -> Vec<String> {\n    let mut deduped = values\n        .iter()\n        .map(|value| value.trim())\n        .filter(|value| !value.is_empty())\n        .map(ToString::to_string)\n        .collect::<Vec<_>>();\n    deduped.sort_unstable();\n    deduped.dedup();\n    deduped\n}\n\nfn now_rfc3339() -> String {\n    let secs = SystemTime::now()","sourceCodeStart":252,"sourceCodeEnd":288,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/security/estop.rs#L252-L288","documentation":"normalize_tool_name found characters outside the allowed set. After trim + lowercase, a tool name may contain only ASCII alphanumerics, underscore, and hyphen; anything else (dots, spaces, slashes, unicode) is rejected so tool matching stays unambiguous.","triggerScenarios":"Passing names like \"fs.read_file\" (dot), \"web search\" (space), \"tools/*\" (slash), or a display label with unicode; names sourced from unvalidated config or user input.","commonSituations":"Using the tool's display/title string instead of its identifier; copy-pasting tool names from docs that pretty-print them; generating selectors from natural-language input.","solutions":["Replace illegal characters: use fs_read instead of fs.read, web_search instead of web search.","Use the tool's declared identifier (the name it is invoked by), not its description.","Pre-validate with the same charset rule: trim, lowercase, then all chars in [a-z0-9_-].","If a tool genuinely needs other characters, wrap/alias it behind a compliant name."],"exampleFix":"// before\nestop.resume(ResumeSelector::Tools(vec![\"fs.read file\".into()]), None, None)?;\n\n// after\nestop.resume(ResumeSelector::Tools(vec![\"fs_read_file\".into()]), None, None)?;","handlingStrategy":"validation","validationCode":"let ok = raw.chars().all(|c| c.is_ascii_alphanumeric() || \"_-\".contains(c))\n    && !raw.trim().is_empty();","typeGuard":"fn is_valid_tool_name(raw: &str) -> bool {\n    let v = raw.trim().to_ascii_lowercase();\n    !v.is_empty() && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')\n}","tryCatchPattern":"Err(e) if e.to_string().contains(\"contains invalid characters\") => {\n    // map the raw name to its declared tool identifier (e.g. \"fs.read\" -> \"fs_read\") and retry\n}","preventionTips":["Always pass the tool's declared identifier, never its display label.","Constrain tool-name inputs in forms/config to [a-z0-9_-] with a lint rule.","Sanitize generated selectors (replace spaces/dots with underscores) before calling estop APIs."],"tags":["estop","validation","tool-name","input-validation"],"backgroundTag":"invalid-tool-name","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}