Synced from monorepo

Changes:
- Gate session-lifecycle heap steady state with a dhat soak
- Unbreak merge lifecycle e2e after default model → grok-4.5
- Scan home-scope rules dirs at <root>/rules
- Complete text-input paste and terminal parity
- Gate project roles and personas
- Use canonical editing in dialogs
- Use canonical editing in search bars
- Reject ambiguous MCP tool IDs
- Harden Git operands for plugins
- Simplify queue drain API
- Pass RFC 9207 iss through MCP OAuth token exchange
- Show leader roster when local agents map is empty
- Use canonical editing in Persona views
- Remove marketplace default-skills auto-install and purge old installs
- Use canonical editing in extension forms
- Add canonical dashboard text editing
- Use canonical editing in settings
- Add /summarize as a /recap alias
- Restore previous agent when exiting dashboard
- Use tool_choice auto for compaction
- Settings toggle for snap-prompt-to-top on send
- Update default models to grok-4.5
- Source login shell once for local bash (env + alias/function snapshot)
- Template hardcoded param names in server-native tool descriptions
- Fix System-Reminder XML tag injection in CLAUDE.md via agents_md
- Fix remote workspace-server hardcoding LSP trust (repo code execution risk)
- Clear orphaned tool-call updates at turn end
- Suppress task wake after cancel
- Send x-grok-client-identifier on direct API tool calls
- Harden dashboard peek lease transitions
- Host /btw side panel in live region (minimal mode)
- Bound scroll presentation latency
- Highlight multi-line constructs correctly in diffs and the file viewer
- Block web_fetch non-public IPs; local opt-in is explicit-host only
- Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness
- Follow up clipboard delivery feedback
- Use canonical editing in pickers
- Route TextArea through canonical editor
- Persistent "watching" status row; quieter turn markers
- Gate sensitive edit targets
- Expose agent registry counts and gate session churn on them
- Default coding data sharing to opt-out until server preference applies
- Wire chat attachment ids through gateway prompts
- On auth refresh failure, issue retry
- Forward preview provenance and computer lifecycle state
- Document independent privacy controls and scope /privacy output
- Strip SamplingError Display prefix on rate-limit UI copy
- Stop dumping Cloudflare HTML into Retry failed
- Disable in-place prompt edit (scroll jank on enter)
- Strip forced ANSI color from gh pr view JSON
- Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
grokkybara[bot] 2026-07-18 19:48:28 +01:00
commit 7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions

View file

@ -27,8 +27,7 @@ const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
// ─── Description ────────────────────────────────────────────────────
const DESCRIPTION: &str =
"Finds files whose contents match the pattern and lists them by modification time.";
const DESCRIPTION: &str = "Finds files whose contents match the ${{ params.search.pattern }} and lists them by modification time.";
// ─── Input ──────────────────────────────────────────────────────────

View file

@ -1425,8 +1425,8 @@ impl BashTool {
r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output.
Usage notes:
- You can specify an optional timeout in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). ${%- if auto_background_on_timeout %} If not specified, commands exceeding the default timeout will be automatically backgrounded instead of killed. You will receive a task_id to check output later.${%- else %} If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.${%- endif %}
- Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period). Descendants that did not detach via `setsid` / `nohup` will also be killed.${%- endif %} `timeout: 0` in `${%- if params is defined and params.execute is defined and params.execute.is_background %}${{ params.execute.is_background }}${%- else %}background${%- endif %}: true` mode disables the wrapper timeout entirely; the child's lifetime is owned by the model via ${{ tools.by_kind.kill_task_action }}.
- You can specify an optional ${{ params.execute.timeout }} in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). ${%- if auto_background_on_timeout %} If not specified, commands exceeding the default timeout will be automatically backgrounded instead of killed. You will receive a task_id to check output later.${%- else %} If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.${%- endif %}
- Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period). Descendants that did not detach via `setsid` / `nohup` will also be killed.${%- endif %} `${{ params.execute.timeout }}: 0` in `${%- if params is defined and params.execute is defined and params.execute.is_background %}${{ params.execute.is_background }}${%- else %}background${%- endif %}: true` mode disables the wrapper timeout entirely; the child's lifetime is owned by the model via ${{ tools.by_kind.kill_task_action }}.
- If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you.
- You can use the ${{ params.execute.is_background }} parameter to run the command in the background (e.g., dev servers, long builds): it returns a task_id immediately and keeps running in the background. You are notified on completion, so do not poll or sleep-wait for it.${%- if has_unix_utilities %} You do not need to use '&' at the end of the command when using this parameter.${%- endif %}
${%- if shell_uses_semicolon %}
@ -1441,7 +1441,7 @@ ${%- endif %}"#
r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output.
Usage notes:
- You can specify an optional timeout in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.
- You can specify an optional ${{ params.execute.timeout }} in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.
- Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period).${%- endif %}
- If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you.
${%- if shell_uses_semicolon %}
@ -4668,6 +4668,40 @@ mod tests {
renderer.render_with_extra(template, &extras).unwrap()
}
#[test]
fn description_tracks_renamed_timeout() {
let renderer = TemplateRenderer::new(
HashMap::from([
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(ToolKind::KillTaskAction, "kill_task".to_string()),
]),
HashMap::from([(
ToolKind::Execute,
HashMap::from([
("timeout".to_string(), "max_wait".to_string()),
("is_background".to_string(), "is_background".to_string()),
]),
)]),
);
let extras = serde_json::json!({
"auto_background_on_timeout": true,
"is_windows": false,
"shell_uses_semicolon": false,
"has_unix_utilities": true,
});
let out = renderer
.render_with_extra(BashTool::default_description_template_enabled(), &extras)
.unwrap();
assert!(
out.contains("optional max_wait in milliseconds") && out.contains("`max_wait: 0`"),
"renamed timeout must appear:\n{out}"
);
assert!(
!out.contains("optional timeout in milliseconds") && !out.contains("`timeout: 0`"),
"canonical timeout must not remain after rename:\n{out}"
);
}
#[test]
fn unix_shell_omits_utility_and_chaining_notes() {
let out = render(BashTool::default_description_template_enabled(), true);

View file

@ -249,9 +249,9 @@ impl crate::types::tool_metadata::ToolMetadata for GrepTool {
r#"Search file contents with regular expressions (ripgrep).
- Full regex syntax, so escape literal special characters: `functionCall\(`, or `interface\{\}` to find interface{} in Go.
- Pass the pattern as a raw regex string no surrounding quotes.
- Pass ${{ params.search.pattern }} as a raw regex string no surrounding quotes.
- Respects .gitignore unless you pass a broad glob like '--glob *'.
- Only filter by 'type' or 'glob' when you are sure of the file type; import paths may not match source file types (.js vs .ts).
- Only filter by '${{ params.search.type }}' or '${{ params.search.glob }}' when you are sure of the file type; import paths may not match source file types (.js vs .ts).
- Output is ripgrep-style: ':' marks match lines, '-' marks context lines, grouped by file. Large results are capped and report "at least" counts."#
}
}
@ -1639,6 +1639,39 @@ mod tests {
assert!(tool.description_template().contains("regex"));
}
#[test]
fn description_template_tracks_renamed_search_params() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
use crate::types::tool_metadata::ToolMetadata;
use std::collections::HashMap;
let tools = HashMap::from([(ToolKind::Search, "grep".to_string())]);
let params = HashMap::from([(
ToolKind::Search,
HashMap::from([
("pattern".to_string(), "query".to_string()),
("type".to_string(), "filetype".to_string()),
("glob".to_string(), "include".to_string()),
]),
)]);
let rendered = TemplateRenderer::new(tools, params)
.render(ToolMetadata::description_template(&GrepTool))
.unwrap();
assert!(
rendered.contains("Pass query as a raw regex")
&& rendered.contains("'filetype'")
&& rendered.contains("'include'"),
"renamed search params must appear:\n{rendered}"
);
assert!(
!rendered.contains("Pass pattern as")
&& !rendered.contains("'type'")
&& !rendered.contains("'glob'"),
"canonical search param names must not remain after rename:\n{rendered}"
);
}
#[tokio::test]
async fn tool_grep_no_matches() {
let tmp = TempDir::new().unwrap();

View file

@ -85,6 +85,7 @@ impl crate::types::tool_metadata::ToolMetadata for KillTaskTool {
subagent_present: true,
bash_present: true,
is_windows: cfg!(not(unix)),
task_id_param: "task_id",
})
});
&DESC
@ -147,6 +148,9 @@ fn kill_task_description(
subagent_present: renderer.tool_for_kind(ToolKind::Task).is_some(),
bash_present: renderer.tool_for_kind(ToolKind::Execute).is_some(),
is_windows: cfg!(not(unix)),
task_id_param: renderer
.param_for_kind(ToolKind::KillTaskAction, "task_id")
.unwrap_or("task_id"),
})
}
@ -437,6 +441,32 @@ mod tests {
}
}
#[test]
fn description_tracks_renamed_task_id() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
use std::collections::HashMap;
let tools = HashMap::from([
(ToolKind::Execute, "run_terminal_command".to_string()),
(ToolKind::Monitor, "monitor".to_string()),
(ToolKind::KillTaskAction, "kill_task".to_string()),
]);
let params = HashMap::from([(
ToolKind::KillTaskAction,
HashMap::from([("task_id".to_string(), "id".to_string())]),
)]);
let rendered = kill_task_description(&TemplateRenderer::new(tools, params), None);
assert!(
rendered.contains("Pass its id (a monitor's id is returned by monitor)"),
"renamed task_id must appear in pass-line and monitor aside:\n{rendered}"
);
assert!(
!rendered.contains("task_id"),
"canonical task_id must not remain after rename:\n{rendered}"
);
}
/// The kill mechanism is OS-level: Windows describes Job Object termination,
/// Unix/Git Bash describe SIGTERM/SIGKILL.
#[test]

View file

@ -26,7 +26,7 @@ impl crate::types::tool_metadata::ToolMetadata for KillTerminalCommandTool {
r#"Terminate a running background terminal command${%- if tools.by_kind.monitor %} or monitor${%- endif %}.
Usage notes:
- Pass its task_id${%- if tools.by_kind.monitor %} (a monitor's task_id is returned by ${{ tools.by_kind.monitor }})${%- endif %}.
- Pass its ${{ params.kill_task_action.task_id }}${%- if tools.by_kind.monitor %} (a monitor's ${{ params.kill_task_action.task_id }} is returned by ${{ tools.by_kind.monitor }})${%- endif %}.
- ${%- if is_windows %} Terminates the Job Object of${%- else %} Sends SIGTERM/SIGKILL to${%- endif %} a background command${%- if tools.by_kind.monitor %} or monitor${%- endif %}.
- Returns success if the command was killed or had already exited."#
}
@ -148,6 +148,36 @@ mod tests {
);
}
#[test]
fn description_template_tracks_renamed_task_id() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
use std::collections::HashMap;
let tools = HashMap::from([
(ToolKind::Monitor, "monitor".to_string()),
(
ToolKind::KillTaskAction,
"kill_terminal_command".to_string(),
),
]);
let params = HashMap::from([(
ToolKind::KillTaskAction,
HashMap::from([("task_id".to_string(), "id".to_string())]),
)]);
let rendered = TemplateRenderer::new(tools, params)
.render(ToolMetadata::description_template(&KillTerminalCommandTool))
.unwrap();
assert!(
rendered.contains("Pass its id (a monitor's id is returned by monitor)"),
"renamed task_id must appear in pass-line and monitor aside:\n{rendered}"
);
assert!(
!rendered.contains("task_id"),
"canonical task_id must not remain after rename:\n{rendered}"
);
}
#[tokio::test]
async fn delegates_kill_killed() {
let resources = resources_with_terminal(KillOutcome::Killed);

View file

@ -103,7 +103,7 @@ fn extract_pptx_text(file_bytes: Vec<u8>) -> Result<ReadFileOutput, String> {
pub(crate) const DESCRIPTION_FULL: &str = r#"Read a file.
Usage:
- The target_file parameter can be a relative path in the workspace or an absolute path
- The ${{ params.read.target_file }} parameter can be a relative path in the workspace or an absolute path
- By default, it reads up to {max_lines_read} lines starting from the beginning of the file
- Results are returned with line numbers starting at 1. The format is: LINE_NUMBERLINE_CONTENT
- This tool can read PDF files (.pdf), PowerPoint files (.pptx), Jupyter notebooks (.ipynb files), and image files (e.g. PNG, JPG, etc).

View file

@ -682,6 +682,9 @@ impl crate::types::tool_metadata::ToolMetadata for TaskOutputTool {
read_tool: Some("read_file"),
bash_background_param: Some("is_background"),
subagent_background_param: Some("run_in_background"),
task_ids_param: "task_ids",
timeout_ms_param: "timeout_ms",
task_id_param: "task_id",
})
});
&DESC
@ -739,6 +742,16 @@ fn task_output_description(
read_tool: renderer.tool_for_kind(ToolKind::Read),
bash_background_param: renderer.param_for_kind(ToolKind::Execute, "is_background"),
subagent_background_param: renderer.param_for_kind(ToolKind::Task, "run_in_background"),
task_ids_param: renderer
.param_for_kind(ToolKind::BackgroundTaskAction, "task_ids")
.unwrap_or("task_ids"),
timeout_ms_param: renderer
.param_for_kind(ToolKind::BackgroundTaskAction, "timeout_ms")
.unwrap_or("timeout_ms"),
// Same singular id name kill_task uses in its monitor aside.
task_id_param: renderer
.param_for_kind(ToolKind::KillTaskAction, "task_id")
.unwrap_or("task_id"),
})
}
@ -1070,6 +1083,59 @@ mod tests {
}
}
#[test]
fn description_tracks_renamed_task_ids_and_timeout_ms() {
use crate::types::template_renderer::TemplateRenderer;
use std::collections::HashMap;
let tools = HashMap::from([
(ToolKind::Execute, "run_terminal_command".to_string()),
(ToolKind::Monitor, "monitor".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
(ToolKind::KillTaskAction, "kill_task".to_string()),
]);
let params = HashMap::from([
(
ToolKind::Execute,
HashMap::from([("is_background".to_string(), "is_background".to_string())]),
),
(
ToolKind::BackgroundTaskAction,
HashMap::from([
("task_ids".to_string(), "process_ids".to_string()),
("timeout_ms".to_string(), "max_wait".to_string()),
]),
),
(
ToolKind::KillTaskAction,
HashMap::from([("task_id".to_string(), "id".to_string())]),
),
]);
let rendered = task_output_description(&TemplateRenderer::new(tools, params), None);
assert!(
rendered.contains("Pass process_ids with"),
"renamed task_ids must appear:\n{rendered}"
);
assert!(
rendered.contains("Omit max_wait or pass 0")
&& rendered.contains("positive max_wait wait"),
"renamed timeout_ms must appear:\n{rendered}"
);
assert!(
rendered.contains("a monitor's id is returned by monitor"),
"renamed kill_task task_id must appear in monitor aside:\n{rendered}"
);
assert!(
!rendered.contains("task_ids")
&& !rendered.contains("timeout_ms")
&& !rendered.contains("task_id"),
"canonical param names must not remain after rename:\n{rendered}"
);
}
#[tokio::test]
async fn get_task_running() {
let snapshot = make_snapshot("task-1", false, None);

View file

@ -25,8 +25,8 @@ impl crate::types::tool_metadata::ToolMetadata for GetTerminalCommandOutputTool
r#"Get output and status from a background terminal command${%- if tools.by_kind.monitor %} or monitor${%- endif %}.
Usage notes:
- Pass task_ids with one or more ids from ${{ params.execute.is_background }}=true commands${%- if tools.by_kind.monitor %} (a monitor's task_id is returned by ${{ tools.by_kind.monitor }})${%- endif %}; for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete
- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at ~10 min
- Pass ${{ params.background_task_action.task_ids }} with one or more ids from ${{ params.execute.is_background }}=true commands${%- if tools.by_kind.monitor %} (a monitor's ${{ params.kill_task_action.task_id }} is returned by ${{ tools.by_kind.monitor }})${%- endif %}; for a single task use a one-element array. Multiple ids with a positive ${{ params.background_task_action.timeout_ms }} wait until all complete
- Omit ${{ params.background_task_action.timeout_ms }} or pass 0 for a non-blocking status snapshot; set a positive ${{ params.background_task_action.timeout_ms }} to wait up to that many milliseconds, capped at ~10 min
- Returns current output, status, and exit code if completed${%- if tools.by_kind.read %}
- If output is large, use ${{ tools.by_kind.read }} on the output_file path${%- endif %}"#
}

View file

@ -94,12 +94,19 @@ impl WebFetchClient {
}
}
// SSRF check.
ssrf::check_ssrf(&url).await?;
// SSRF check (policy from tool params — not process env at call time).
ssrf::check_ssrf(&url, self.params.allow_local()).await?;
// Make request and build output.
let http = self.http.get_or_rebuild()?;
let result = match fetch_url(&http, &url, self.params.max_content_length()).await {
let result = match fetch_url(
&http,
&url,
self.params.max_content_length(),
self.params.allow_local(),
)
.await
{
Ok(result) => result,
Err(e @ WebFetchError::HttpRequest(_)) => {
self.http.invalidate();
@ -301,6 +308,9 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
if let Some(host) = parsed.host_str()
&& host.split('.').count() < 2
// `localhost` is a single-label name; SSRF still requires
// allow_local for explicit local hosts.
&& !ssrf::is_explicit_local_host(host)
{
return Err(WebFetchError::SingleLabelHost {
host: host.to_string(),
@ -310,11 +320,20 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
Ok(parsed)
}
/// Upgrade `http://` to `https://`.
/// Upgrade `http://` to `https://`, except for explicit loopback hosts.
///
/// Local dev servers almost always speak plain HTTP; forcing TLS would break
/// `http://127.0.0.1` / `http://localhost` when local binding is opted in.
fn upgrade_to_https(url: &mut Url) {
if url.scheme() == "http" {
let _ = url.set_scheme("https");
if url.scheme() != "http" {
return;
}
if let Some(host) = url.host_str()
&& ssrf::is_explicit_local_host(host)
{
return;
}
let _ = url.set_scheme("https");
}
// ───────────────────────────────────────────────────────────────────────────
@ -335,16 +354,25 @@ enum FetchResult {
}
/// Fetch a URL with manual same-host redirect handling.
///
/// Re-runs SSRF checks on every hop so DNS rebinding between redirects cannot
/// sneak a previously-blocked address past the initial check (partial TOCTOU
/// mitigation; peer IP on the live TCP connection is not available from reqwest).
async fn fetch_url(
client: &reqwest::Client,
url: &Url,
max_content_length: usize,
allow_local: bool,
) -> Result<FetchResult, WebFetchError> {
let mut current_url = url.clone();
let mut hops = 0;
// Loop to follow redirects under the same host.
loop {
// Re-check on every hop (including the first) so a rebinding name that
// was public at the pre-fetch check cannot become loopback/private here.
ssrf::check_ssrf(&current_url, allow_local).await?;
let resp = client
.get(current_url.as_str())
.header(USER_AGENT, USER_AGENT_STRING)
@ -367,10 +395,15 @@ async fn fetch_url(
// Follow same host; break on cross-host.
if let Some(location) = resp.headers().get("location") {
let location_str = location.to_str().unwrap_or("");
let next_url = current_url
let mut next_url = current_url
.join(location_str)
.map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?;
if is_same_host(&current_url, &next_url) {
// Re-apply https upgrade on every hop: Location may be
// absolute `http://…` and would otherwise silently
// downgrade an https fetch. Local hosts still skip TLS.
upgrade_to_https(&mut next_url);
// check_ssrf runs at the top of the next loop iteration.
current_url = next_url;
continue;
}
@ -407,13 +440,11 @@ async fn fetch_url(
}
}
/// Exact host equality — no `www.` stripping. Distinct DNS labels (even when
/// one is a `www` subdomain of the other) have independent A records and must
/// surface as cross-host redirects rather than auto-follow.
fn is_same_host(a: &Url, b: &Url) -> bool {
fn strip_www(h: &str) -> &str {
h.strip_prefix("www.").unwrap_or(h)
}
let host_a = a.host_str().unwrap_or("");
let host_b = b.host_str().unwrap_or("");
strip_www(host_a) == strip_www(host_b)
a.host_str() == b.host_str()
}
// ───────────────────────────────────────────────────────────────────────────
@ -877,11 +908,28 @@ mod tests {
#[test]
fn validate_url_rejects_single_label_hosts() {
assert!(validate_url("http://localhost:8080/foo").is_err());
// localhost is an explicit local host; SSRF still blocks it unless
// allow_local is set on tool params.
assert!(validate_url("http://localhost:8080/foo").is_ok());
assert!(validate_url("http://intranet/foo").is_err());
assert!(validate_url("http://metadata/computeMetadata").is_err());
}
#[test]
fn upgrade_to_https_skips_explicit_local_hosts() {
let mut local = Url::parse("http://127.0.0.1:8080/").unwrap();
upgrade_to_https(&mut local);
assert_eq!(local.scheme(), "http");
let mut localhost = Url::parse("http://localhost:3000/").unwrap();
upgrade_to_https(&mut localhost);
assert_eq!(localhost.scheme(), "http");
let mut public = Url::parse("http://example.com/").unwrap();
upgrade_to_https(&mut public);
assert_eq!(public.scheme(), "https");
}
#[test]
fn validate_url_rejects_credentials() {
assert!(validate_url("https://user:pass@example.com/foo").is_err());
@ -931,11 +979,11 @@ mod tests {
}
#[test]
fn same_host_www_stripping() {
fn www_subdomain_is_cross_host() {
let a = Url::parse("https://example.com/a").unwrap();
let c = Url::parse("https://www.example.com/a").unwrap();
assert!(is_same_host(&a, &c));
assert!(is_same_host(&c, &a));
assert!(!is_same_host(&a, &c));
assert!(!is_same_host(&c, &a));
}
#[test]
@ -945,6 +993,19 @@ mod tests {
assert!(!is_same_host(&a, &d));
}
#[test]
fn same_host_redirect_location_reupgrades_http() {
// Absolute http Location on an https origin must not stay http when
// followed as a same-host hop (upgrade_to_https reapplied each hop).
let origin = Url::parse("https://example.com/start").unwrap();
let mut next = origin.join("http://example.com/next").unwrap();
assert_eq!(next.scheme(), "http");
assert!(is_same_host(&origin, &next));
upgrade_to_https(&mut next);
assert_eq!(next.scheme(), "https");
assert_eq!(next.as_str(), "https://example.com/next");
}
// ── Content type detection ──────────────────────────────────────────
#[test]

View file

@ -40,6 +40,12 @@ pub struct WebFetchParams {
/// routed through this URL.
#[serde(default)]
pub proxy_endpoint: Option<String>,
/// When true, allow fetches to **explicit** loopback hosts only
/// (`localhost`, `127.0.0.0/8`, `::1`). Private/metadata stay blocked.
/// Default: `false` (fail closed). Set via `[toolset.web_fetch]
/// allow_local = true` or `GROK_WEB_FETCH_ALLOW_LOCAL=1`.
#[serde(default)]
pub allow_local: Option<bool>,
}
register_resource!("grok_build", "WebFetch", WebFetchParams);
@ -71,6 +77,10 @@ impl WebFetchParams {
self.context_window_tokens.unwrap_or(128_000)
}
pub fn allow_local(&self) -> bool {
self.allow_local.unwrap_or(false)
}
pub fn allowed_domains(&self) -> Vec<String> {
match &self.allowed_domains {
Some(v) => v.clone(),

View file

@ -1,87 +1,133 @@
//! SSRF (Server-Side Request Forgery) protection for `web_fetch`.
//!
//! Validates that resolved IP addresses are not in private, link-local, or
//! cloud metadata ranges before allowing outbound HTTP requests.
//! Policy:
//! - Non-public addresses (loopback, RFC 1918, link-local, CGNAT, TEST-NET,
//! multicast, etc.) are blocked by default.
//! - Local access is opt-in via tool params (`WebFetchParams::allow_local`,
//! set from `[toolset.web_fetch] allow_local` or `GROK_WEB_FETCH_ALLOW_LOCAL=1`).
//! Even when enabled, only **explicit** loopback hosts are allowed
//! (`localhost`, `127.0.0.0/8` literals, `::1`). A public hostname that
//! resolves to loopback/private stays blocked.
//!
//! Reference: [IANA IPv4 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv4-special-registry/)
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::Url;
use super::error::WebFetchError;
/// Returns `true` if an IP address is in a private, link-local, or cloud
/// metadata range that should be blocked to prevent SSRF attacks.
///
/// **Allowed:** loopback (`127.x` / `::1`) for local development.
/// **Blocked:** RFC 1918, link-local, CGNAT/cloud metadata, unspecified.
pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool {
/// Hostnames/IP literals that may reach loopback when local binding is
/// enabled. Public names that *resolve* to loopback are not included — that
/// closes DNS rebinding through a non-local hostname.
pub(crate) fn is_explicit_local_host(host: &str) -> bool {
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
let host = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(&host);
// Drop IPv6 zone id if present (`fe80::1%lo0`).
let host = host.split('%').next().unwrap_or(host);
if host == "localhost" {
return true;
}
if let Ok(ip) = host.parse::<IpAddr>() {
return ip.is_loopback();
}
false
}
/// Returns `true` if an IP is not globally routable and should be treated as
/// local/private for SSRF.
pub(crate) fn is_non_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
// Loopback (127.0.0.0/8) — allowed for local dev servers.
if octets[0] == 127 {
return false;
}
// RFC 1918: 10.0.0.0/8 — private network.
if octets[0] == 10 {
return true;
}
// RFC 1918: 172.16.0.0/12 — private network.
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
return true;
}
// RFC 1918: 192.168.0.0/16 — private network.
if octets[0] == 192 && octets[1] == 168 {
return true;
}
// RFC 3927: 169.254.0.0/16 — link-local.
// Includes AWS/GCP/Azure metadata endpoint 169.254.169.254.
if octets[0] == 169 && octets[1] == 254 {
return true;
}
// RFC 6598: 100.64.0.0/10 — CGNAT / shared address space.
// Used by some cloud providers for internal metadata services.
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
return true;
}
// 0.0.0.0 — unspecified address.
if v4.is_unspecified() {
return true;
}
false
}
IpAddr::V6(v6) => {
// ::1 — loopback, allowed for local dev.
if v6.is_loopback() {
return false;
}
// :: — unspecified.
if v6.is_unspecified() {
return true;
}
// IPv4-mapped IPv6 (::ffff:x.x.x.x) — delegate to v4 checks.
if let Some(v4) = v6.to_ipv4_mapped() {
return is_blocked_ip(&IpAddr::V4(v4));
}
let segments = v6.segments();
// RFC 4291: fe80::/10 — link-local unicast.
if segments[0] & 0xffc0 == 0xfe80 {
return true;
}
// RFC 4193: fc00::/7 — unique local address (ULA).
if segments[0] & 0xfe00 == 0xfc00 {
return true;
}
false
}
IpAddr::V4(v4) => is_non_public_ipv4(v4),
IpAddr::V6(v6) => is_non_public_ipv6(v6),
}
}
fn is_non_public_ipv4(ip: Ipv4Addr) -> bool {
ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_unspecified()
|| ip.is_multicast()
|| ip.is_broadcast()
// "This network" (RFC 1122) 0.0.0.0/8
|| ipv4_in_cidr(ip, [0, 0, 0, 0], 8)
// CGNAT (RFC 6598) 100.64.0.0/10 — cloud metadata-ish
|| ipv4_in_cidr(ip, [100, 64, 0, 0], 10)
// IETF Protocol Assignments (RFC 6890) 192.0.0.0/24
|| ipv4_in_cidr(ip, [192, 0, 0, 0], 24)
// TEST-NET-1 (RFC 5737)
|| ipv4_in_cidr(ip, [192, 0, 2, 0], 24)
// Benchmarking (RFC 2544)
|| ipv4_in_cidr(ip, [198, 18, 0, 0], 15)
// TEST-NET-2 / TEST-NET-3
|| ipv4_in_cidr(ip, [198, 51, 100, 0], 24)
|| ipv4_in_cidr(ip, [203, 0, 113, 0], 24)
// Reserved (RFC 6890) 240.0.0.0/4
|| ipv4_in_cidr(ip, [240, 0, 0, 0], 4)
}
fn ipv4_in_cidr(ip: Ipv4Addr, base: [u8; 4], prefix: u8) -> bool {
let ip = u32::from(ip);
let base = u32::from(Ipv4Addr::from(base));
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
(ip & mask) == (base & mask)
}
fn is_non_public_ipv6(ip: Ipv6Addr) -> bool {
if let Some(v4) = ip.to_ipv4_mapped() {
return is_non_public_ipv4(v4);
}
// Anything not globally routable: loopback, ULA, link-local, unspecified, multicast.
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
}
/// Loopback including IPv4-mapped forms (`::ffff:127.0.0.1`).
///
/// `IpAddr::is_loopback` is false for mapped addresses even when the embedded
/// v4 is loopback, so local opt-in must use this helper.
fn is_loopback_addr(ip: IpAddr) -> bool {
if ip.is_loopback() {
return true;
}
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()),
IpAddr::V4(_) => false,
}
}
/// Whether a resolved address is blocked for this request host.
///
/// Dual-gate: even with local binding allowed, only explicit loopback hosts
/// may use loopback IPs; private/link-local never open via this flag.
pub(crate) fn is_blocked_for_host(ip: IpAddr, host: &str, allow_local: bool) -> bool {
if !is_non_public_ip(ip) {
return false;
}
if allow_local && is_loopback_addr(ip) && is_explicit_local_host(host) {
return false;
}
true
}
/// Resolve hostname via DNS and verify none of the resolved addresses are
/// in blocked private/link-local ranges.
pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
/// blocked under the SSRF policy.
///
/// `allow_local` comes from tool config (`WebFetchParams::allow_local`); it is
/// not read from the environment here so the agent cannot flip the policy.
pub(crate) async fn check_ssrf(url: &Url, allow_local: bool) -> Result<(), WebFetchError> {
let host = url
.host_str()
.ok_or_else(|| WebFetchError::SingleLabelHost {
@ -90,7 +136,7 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
// If the host is already a literal IP, check it directly.
if let Ok(ip) = host.parse::<IpAddr>() {
if is_blocked_ip(&ip) {
if is_blocked_for_host(ip, host, allow_local) {
return Err(WebFetchError::SsrfBlocked {
host: host.to_string(),
ip,
@ -114,9 +160,12 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
return Err(WebFetchError::DnsEmpty(host.to_string()));
}
// Any non-public address blocks the request. When allow_local is on,
// only *explicit* loopback hosts may use loopback IPs — a rebinding name
// that resolves to 127.0.0.1 stays blocked.
addrs
.iter()
.find(|addr| is_blocked_ip(&addr.ip()))
.find(|addr| is_blocked_for_host(addr.ip(), host, allow_local))
.map_or(Ok(()), |addr| {
Err(WebFetchError::SsrfBlocked {
host: host.to_string(),
@ -133,82 +182,206 @@ mod tests {
#[test]
fn blocks_rfc1918_10x() {
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap()));
assert!(is_non_public_ip("10.0.0.1".parse().unwrap()));
assert!(is_blocked_for_host(
"10.0.0.1".parse().unwrap(),
"10.0.0.1",
true
));
}
#[test]
fn blocks_rfc1918_172x() {
assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap()));
assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap()));
assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap()));
assert!(is_non_public_ip("172.16.0.1".parse().unwrap()));
assert!(is_non_public_ip("172.31.255.255".parse().unwrap()));
assert!(!is_non_public_ip("172.15.0.1".parse().unwrap()));
assert!(!is_non_public_ip("172.32.0.1".parse().unwrap()));
}
#[test]
fn blocks_rfc1918_192168() {
assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap()));
assert!(is_non_public_ip("192.168.0.1".parse().unwrap()));
assert!(is_non_public_ip("192.168.255.255".parse().unwrap()));
}
#[test]
fn blocks_link_local() {
assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap()));
assert!(is_non_public_ip("169.254.0.1".parse().unwrap()));
assert!(is_non_public_ip("169.254.169.254".parse().unwrap()));
}
#[test]
fn blocks_cgnat_cloud_metadata() {
assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap()));
assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap()));
assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap()));
assert!(!is_blocked_ip(&"100.128.0.1".parse().unwrap()));
assert!(is_non_public_ip("100.64.0.1".parse().unwrap()));
assert!(is_non_public_ip("100.127.255.255".parse().unwrap()));
assert!(!is_non_public_ip("100.63.0.1".parse().unwrap()));
assert!(!is_non_public_ip("100.128.0.1".parse().unwrap()));
}
#[test]
fn blocks_unspecified() {
assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap()));
assert!(is_blocked_ip(&"::".parse().unwrap()));
assert!(is_non_public_ip("0.0.0.0".parse().unwrap()));
assert!(is_non_public_ip("::".parse().unwrap()));
}
#[test]
fn allows_loopback() {
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap()));
assert!(!is_blocked_ip(&"127.0.0.2".parse().unwrap()));
assert!(!is_blocked_ip(&"::1".parse().unwrap()));
fn blocks_testnet_reserved_and_this_network() {
// TEST-NET / reserved / this-network special-purpose ranges
assert!(is_non_public_ip("192.0.2.1".parse().unwrap()));
assert!(is_non_public_ip("198.51.100.1".parse().unwrap()));
assert!(is_non_public_ip("203.0.113.1".parse().unwrap()));
assert!(is_non_public_ip("240.0.0.1".parse().unwrap()));
assert!(is_non_public_ip("0.1.2.3".parse().unwrap()));
assert!(is_non_public_ip("198.18.0.1".parse().unwrap()));
}
#[test]
fn blocks_loopback_by_default() {
assert!(is_blocked_for_host(
"127.0.0.1".parse().unwrap(),
"127.0.0.1",
false
));
assert!(is_blocked_for_host(
"127.0.0.2".parse().unwrap(),
"127.0.0.2",
false
));
assert!(is_blocked_for_host("::1".parse().unwrap(), "::1", false));
assert!(is_blocked_for_host(
"127.0.0.1".parse().unwrap(),
"localhost",
false
));
}
#[test]
fn allows_explicit_loopback_when_local_binding_enabled() {
assert!(!is_blocked_for_host(
"127.0.0.1".parse().unwrap(),
"127.0.0.1",
true
));
assert!(!is_blocked_for_host(
"127.0.0.2".parse().unwrap(),
"127.0.0.2",
true
));
assert!(!is_blocked_for_host("::1".parse().unwrap(), "::1", true));
assert!(!is_blocked_for_host(
"127.0.0.1".parse().unwrap(),
"localhost",
true
));
assert!(!is_blocked_for_host(
"127.0.0.1".parse().unwrap(),
"localhost.",
true
));
// IPv4-mapped loopback (common dual-stack DNS result for localhost).
assert!(!is_blocked_for_host(
"::ffff:127.0.0.1".parse().unwrap(),
"localhost",
true
));
assert!(!is_blocked_for_host(
"::ffff:127.0.0.1".parse().unwrap(),
"127.0.0.1",
true
));
// Metadata / private ranges stay blocked even with the opt-in.
assert!(is_blocked_for_host(
"169.254.169.254".parse().unwrap(),
"169.254.169.254",
true
));
assert!(is_blocked_for_host(
"10.0.0.1".parse().unwrap(),
"10.0.0.1",
true
));
// Mapped private is still blocked under local opt-in.
assert!(is_blocked_for_host(
"::ffff:10.0.0.1".parse().unwrap(),
"localhost",
true
));
}
#[test]
fn rebinding_hostname_to_loopback_stays_blocked() {
// Hostnames that resolve to local IPs stay blocked even when local
// binding is allowed — only explicit local hosts open loopback.
assert!(is_blocked_for_host(
"127.0.0.1".parse().unwrap(),
"evil.example.com",
true
));
assert!(is_blocked_for_host(
"127.0.0.1".parse().unwrap(),
"localtest.me",
true
));
assert!(is_blocked_for_host(
"::1".parse().unwrap(),
"attacker.test",
true
));
}
#[test]
fn explicit_local_host_detection() {
assert!(is_explicit_local_host("localhost"));
assert!(is_explicit_local_host("LOCALHOST."));
assert!(is_explicit_local_host("127.0.0.1"));
assert!(is_explicit_local_host("127.1.2.3"));
assert!(is_explicit_local_host("::1"));
assert!(is_explicit_local_host("[::1]"));
assert!(!is_explicit_local_host("example.com"));
assert!(!is_explicit_local_host("10.0.0.1"));
assert!(!is_explicit_local_host("notlocalhost"));
}
#[test]
fn allows_public_ips() {
assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap()));
assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap()));
assert!(!is_blocked_ip(&"142.250.80.46".parse().unwrap()));
assert!(!is_non_public_ip("1.1.1.1".parse().unwrap()));
assert!(!is_non_public_ip("8.8.8.8".parse().unwrap()));
assert!(!is_non_public_ip("142.250.80.46".parse().unwrap()));
assert!(!is_blocked_for_host(
"1.1.1.1".parse().unwrap(),
"1.1.1.1",
false
));
}
// ── IPv6 ────────────────────────────────────────────────────────────
#[test]
fn blocks_ipv6_link_local() {
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
assert!(is_non_public_ip("fe80::1".parse().unwrap()));
}
#[test]
fn blocks_ipv6_unique_local() {
assert!(is_blocked_ip(&"fc00::1".parse().unwrap()));
assert!(is_blocked_ip(&"fd00::1".parse().unwrap()));
assert!(is_non_public_ip("fc00::1".parse().unwrap()));
assert!(is_non_public_ip("fd00::1".parse().unwrap()));
}
#[test]
fn blocks_ipv4_mapped_ipv6_private() {
assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_blocked_ip(
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
assert!(is_non_public_ip(
"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()
));
assert!(is_non_public_ip(
"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
));
}
#[test]
fn allows_ipv4_mapped_ipv6_public() {
assert!(!is_blocked_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
assert!(!is_non_public_ip(
"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()
));
}
// ── check_ssrf integration ──────────────────────────────────────────
@ -216,15 +389,28 @@ mod tests {
#[tokio::test]
async fn ssrf_blocks_ip_literal_private() {
let url = Url::parse("https://10.0.0.1/secret").unwrap();
let result = check_ssrf(&url).await;
let result = check_ssrf(&url, false).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("private"));
}
#[tokio::test]
async fn ssrf_blocks_loopback_literal_by_default() {
let url = Url::parse("http://127.0.0.1:8080/").unwrap();
let result = check_ssrf(&url, false).await;
assert!(result.is_err());
}
#[tokio::test]
async fn ssrf_allows_loopback_literal_when_opted_in() {
let url = Url::parse("http://127.0.0.1:8080/").unwrap();
assert!(check_ssrf(&url, true).await.is_ok());
}
#[tokio::test]
async fn ssrf_allows_ip_literal_public() {
let url = Url::parse("https://1.1.1.1/").unwrap();
let result = check_ssrf(&url).await;
let result = check_ssrf(&url, false).await;
assert!(result.is_ok());
}
}

View file

@ -146,10 +146,10 @@ Content output format:
{grep_context} context (-)
Usage:
- Pattern is a regex: `log.*Error`, `function\s+\w+`, `TODO`
- ${{ params.search.pattern }} is a regex: `log.*Error`, `function\s+\w+`, `TODO`
- Output modes: "content" (default, with anchors), "files_with_matches", "count"
- Use -A, -B, -C for context lines around matches
- Only use 'type' or 'glob' when certain of the file type
- Only use '${{ params.search.type }}' or '${{ params.search.glob }}' when certain of the file type
- Results are capped; truncated results show "at least" counts"#;
/// `hashline_grep` — searches with anchor-annotated results.

View file

@ -84,7 +84,7 @@ Anchors are valid only for the file state at read time — after any edit,
use the fresh anchors returned by ${{ tools.by_kind.edit }} or re-read the file.${%- endif %}
Usage:
- The file_path parameter must be an absolute path, not a relative path
- The ${{ params.read.target_file }} parameter must be an absolute path, not a relative path
- By default reads up to {max_lines_read} lines from the beginning
- Optionally specify offset and limit for large files
- Can read images (PNG, JPG, etc.) and PDF files (each page rendered as an image; use `pages` parameter for PDFs with more than 10 pages, max 20 per call)

View file

@ -65,7 +65,7 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will use the default timeout.
- You can specify an optional ${{ params.execute.timeout }} in milliseconds. If not specified, commands will use the default timeout.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you.
${%- if tools.by_kind.list or tools.by_kind.search or tools.by_kind.read or tools.by_kind.edit or tools.by_kind.write %}
@ -572,6 +572,31 @@ mod tests {
resources
}
#[test]
fn description_template_tracks_renamed_timeout() {
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
use crate::types::tool_metadata::ToolMetadata;
use std::collections::HashMap;
let tools = HashMap::from([(ToolKind::Execute, "bash".to_string())]);
let params = HashMap::from([(
ToolKind::Execute,
HashMap::from([("timeout".to_string(), "max_wait".to_string())]),
)]);
let rendered = TemplateRenderer::new(tools, params)
.render(ToolMetadata::description_template(&BashTool))
.unwrap();
assert!(
rendered.contains("optional max_wait in milliseconds"),
"renamed timeout must appear:\n{rendered}"
);
assert!(
!rendered.contains("optional timeout in milliseconds"),
"canonical timeout must not remain after rename:\n{rendered}"
);
}
fn make_input(command: &str) -> BashInput {
BashInput {
command: command.to_string(),
@ -1106,8 +1131,12 @@ mod tests {
(ToolKind::Read, "read_file".to_string()),
(ToolKind::Edit, "search_replace".to_string()),
(ToolKind::Write, "write".to_string()),
(ToolKind::Execute, "bash".to_string()),
]),
HashMap::new(),
HashMap::from([(
ToolKind::Execute,
HashMap::from([("timeout".to_string(), "timeout".to_string())]),
)]),
)
}

View file

@ -42,15 +42,19 @@ use crate::types::tool::{ToolKind, ToolNamespace};
// Description
// ───────────────────────────────────────────────────────────────────────────
// NOTE: OpenCode's `EditInput` serializes camelCase (`oldString`, `newString`,
// `replaceAll`), so param refs must use the camelCase schema property names —
// the snake_case `params.edit.old_string` keys of the grok_build twin resolve
// to "" here (the kind-params map is keyed by schema property names).
const DESCRIPTION: &str = r#"Performs exact string replacements in files.
Usage:
- You must use your `${{ tools.by_kind.read }}` tool at least once in the conversation before editing.
- When editing text from ${{ tools.by_kind.read }} tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + . Everything after that separator is the actual file content to match. Never include any part of the line number prefix in the ${{ params.edit.old_string }} or ${{ params.edit.new_string }}.
- When editing text from ${{ tools.by_kind.read }} tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + . Everything after that separator is the actual file content to match. Never include any part of the line number prefix in the ${{ params.edit.oldString }} or ${{ params.edit.newString }}.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- The edit will FAIL if `${{ params.edit.old_string }}` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `${{ params.edit.replace_all }}` to change every instance of `${{ params.edit.old_string }}`.
- Use `${{ params.edit.replace_all }}` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
- To create a new file, set ${{ params.edit.old_string }} to an empty string.
- The edit will FAIL if `${{ params.edit.oldString }}` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `${{ params.edit.replaceAll }}` to change every instance of `${{ params.edit.oldString }}`.
- Use `${{ params.edit.replaceAll }}` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
- To create a new file, set ${{ params.edit.oldString }} to an empty string.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked."#;
// ───────────────────────────────────────────────────────────────────────────
@ -70,7 +74,9 @@ pub struct EditInput {
pub old_string: String,
/// The replacement text (must differ from old_string).
#[schemars(description = "The text to replace it with (must be different from old_string)")]
#[schemars(
description = "The text to replace it with (must be different from ${{ params.edit.oldString }})"
)]
pub new_string: String,
/// When true, replace every occurrence of `old_string` (default false).
@ -78,7 +84,9 @@ pub struct EditInput {
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
)]
#[schemars(description = "Replace all occurrences of old_string (default false)")]
#[schemars(
description = "Replace all occurrences of ${{ params.edit.oldString }} (default false)"
)]
pub replace_all: Option<bool>,
}
@ -373,7 +381,7 @@ async fn handle_replacement(
if positions.len() > 1 && !replace_all {
let replace_all_name = crate::types::template_renderer::TemplateRenderer::resolve(
&resources,
"${{ params.edit.replace_all }}",
"${{ params.edit.replaceAll }}",
)
.await?;
return Ok(SearchReplaceOutput::MultipleMatchesFound(format!(
@ -493,10 +501,12 @@ mod tests {
resources.insert(FileSystem(Arc::new(LocalFs)));
resources.insert(NotificationHandle(ToolNotificationHandle::noop()));
// Keys mirror finalize-time seeding: schema property names, which are
// camelCase for OpenCode's EditInput.
let edit_params = std::collections::HashMap::from([
("old_string".to_string(), "old_string".to_string()),
("new_string".to_string(), "new_string".to_string()),
("replace_all".to_string(), "replaceAll".to_string()),
("oldString".to_string(), "oldString".to_string()),
("newString".to_string(), "newString".to_string()),
("replaceAll".to_string(), "replaceAll".to_string()),
]);
resources.insert(TemplateRenderer::new(
std::collections::HashMap::from([(ToolKind::Read, "read_file".to_string())]),
@ -765,8 +775,10 @@ mod tests {
std::collections::HashMap::from([(ToolKind::Read, "file_reader".to_string())]),
std::collections::HashMap::from([(
ToolKind::Edit,
// Keyed by the camelCase schema property name (finalize seeds
// kind params from schema properties).
std::collections::HashMap::from([(
"replace_all".to_string(),
"replaceAll".to_string(),
"replaceEverything".to_string(),
)]),
)]),

View file

@ -34,10 +34,10 @@ Usage:
- Prefer ${{ tools.by_kind.search }} for exact symbol/string searches. Whenever possible, use this instead of terminal grep/rg. This tool is faster and respects .gitignore
- Supports full regex syntax, e.g. `log.*Error`, `function\s+\w+`. Ensure you escape special chars to get exact matches, e.g. `functionCall\(`
- Avoid overly broad glob patterns (e.g., '--glob *') as they bypass .gitignore rules and may be slow
- The pattern field is a raw regex string: do NOT wrap it in quotes or add trailing quote characters unnecessarily
- Only use 'include' when certain of the file type needed. Note: import paths may not match source file types (.js vs .ts)
- The ${{ params.search.pattern }} field is a raw regex string: do NOT wrap it in quotes or add trailing quote characters unnecessarily
- Only use '${{ params.search.include }}' when certain of the file type needed. Note: import paths may not match source file types (.js vs .ts)
- Results are capped for responsiveness; truncated results show "at least" counts.
- Filter files by pattern with the include parameter (e.g. "*.js", "*.{ts,tsx}")
- Filter files by pattern with the ${{ params.search.include }} parameter (e.g. "*.js", "*.{ts,tsx}")
- Returns file paths and line numbers with at least one match sorted by modification time
- Use this tool when you need to find files containing specific patterns"#;

View file

@ -38,7 +38,7 @@ const DESCRIPTION: &str = r#"Reads a file from the local filesystem. You can acc
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- The ${{ params.read.filePath }} parameter must be an absolute path, not a relative path
- By default, it reads up to {max_lines_read} lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than {max_chars_per_line} characters will be truncated