bashツール実装

This commit is contained in:
Keisuke Hirata 2026-05-01 18:14:13 +09:00
parent 6e9ef385c8
commit 97f9b14ceb
No known key found for this signature in database
11 changed files with 793 additions and 66 deletions

View File

@ -1 +1 @@
_memory _staging

View File

@ -156,6 +156,23 @@ impl Scope {
.collect() .collect()
} }
/// Deny rules with their targets resolved to absolute paths.
///
/// Counterpart to [`allow_rules`](Self::allow_rules); together they
/// round-trip through [`ScopeConfig`] for callers that need to
/// rebuild a scope after layering extra rules on top of an
/// already-constructed [`Scope`].
pub fn deny_rules(&self) -> Vec<ScopeRule> {
self.deny
.iter()
.map(|r| ScopeRule {
target: r.target.clone(),
permission: r.permission,
recursive: r.recursive,
})
.collect()
}
/// Iterate over absolute paths granted `Write` by an allow rule. /// Iterate over absolute paths granted `Write` by an allow rule.
/// Subset of [`readable_paths`](Self::readable_paths). /// Subset of [`readable_paths`](Self::readable_paths).
pub fn writable_paths(&self) -> impl Iterator<Item = &Path> { pub fn writable_paths(&self) -> impl Iterator<Item = &Path> {

View File

@ -221,20 +221,49 @@ impl PodController {
}); });
// Register the builtin file-manipulation tools (Read / Write / // Register the builtin file-manipulation tools (Read / Write /
// Edit / Glob / Grep). `ScopedFs` carries the pod-lifetime // Edit / Glob / Grep / Bash). `ScopedFs` carries the pod-
// scope/pwd; `Tracker` is session-scoped — a fresh instance per // lifetime scope/pwd; `Tracker` is session-scoped — a fresh
// controller spawn ensures state from a previous process // instance per controller spawn ensures state from a previous
// lifetime cannot be reused after a resume. The tracker is // process lifetime cannot be reused after a resume. The tracker
// also handed to the Pod itself so Pod-level operations (e.g. // is also handed to the Pod itself so Pod-level operations (e.g.
// context compaction) can ask which files the agent has been // context compaction) can ask which files the agent has been
// touching. // touching.
let fs = tools::ScopedFs::new(scope_for_tools, pwd_for_tools.clone()); //
// Bash spills long outputs to a per-pod subdir under the
// runtime dir. We layer a recursive `allow(Read)` rule for
// that path on top of the user-facing scope so the agent can
// `Read` the saved files without polluting the workspace.
// Same approach memory takes for its deny rules: round-trip
// through `ScopeConfig` and rebuild via `from_config`.
let bash_output_dir = runtime_dir.path().join("bash-output");
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
std::io::Error::other(format!(
"create bash output dir {}: {e}",
bash_output_dir.display()
))
})?;
let mut scope_config = manifest::ScopeConfig {
allow: scope_for_tools.allow_rules(),
deny: scope_for_tools.deny_rules(),
};
scope_config.allow.push(manifest::ScopeRule {
target: bash_output_dir.clone(),
permission: manifest::Permission::Read,
recursive: true,
});
let scope_with_bash = manifest::Scope::from_config(&scope_config)
.map_err(std::io::Error::other)?;
let fs = tools::ScopedFs::new(scope_with_bash, pwd_for_tools.clone());
let tracker = tools::Tracker::new(); let tracker = tools::Tracker::new();
// The same ScopedFs also powers the IPC `ListCompletions` // The same ScopedFs also powers the IPC `ListCompletions`
// query — keep a clone for the FS view we attach below, // query — keep a clone for the FS view we attach below,
// since the tools consume `fs` itself. // since the tools consume `fs` itself.
fs_for_view = fs.clone(); fs_for_view = fs.clone();
worker.register_tools(tools::builtin_tools(fs, tracker.clone())); worker.register_tools(tools::builtin_tools(
fs,
tracker.clone(),
bash_output_dir,
));
// Memory subsystem opt-in. When `[memory]` is present in // Memory subsystem opt-in. When `[memory]` is present in
// the manifest, register the memory-specific Read/Write/Edit // the manifest, register the memory-specific Read/Write/Edit

View File

@ -19,7 +19,7 @@ serde_json = "1.0.149"
sha2 = "0.11.0" sha2 = "0.11.0"
tempfile = "3.27.0" tempfile = "3.27.0"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.51.1", features = ["rt"] } tokio = { version = "1.51.1", features = ["process", "rt", "time"] }
tracing = "0.1.44" tracing = "0.1.44"
[dev-dependencies] [dev-dependencies]

581
crates/tools/src/bash.rs Normal file
View File

@ -0,0 +1,581 @@
//! `Bash` tool — execute shell commands in a one-shot, stateless way.
//!
//! Each call runs `bash -c <command>` via [`tokio::process::Command`].
//! The wrapper redirects all output to a file so we never have to read
//! from a pipe (which would expose us to bg-pipe hangs). There is no
//! shell session: every call starts fresh at `cwd`, so the agent must
//! chain `cd <dir> && cmd` when it wants to operate elsewhere. This
//! mirrors Claude Code's own Bash tool — predictable, no hidden state.
//!
//! Output handling: when output is short (≤ 80 lines, ≤ 12 KiB) it is
//! returned inline and the file is cleaned up. When it is longer the
//! full output is left on disk and only the **last 80 lines** are
//! returned, prefixed with the saved file's path. This sidesteps the
//! Worker's blanket `ToolOutputLimits` (default 16 KiB), which would
//! otherwise drop the *tail* of the output — usually the most useful
//! part (errors, exit messages, summary). The saved file lives under
//! a caller-supplied directory that the parent has added to the
//! `ScopedFs` allow set, so the agent can inspect it via either Read
//! or a follow-up Bash call.
//!
//! Filesystem and network access are NOT mediated by `ScopedFs`: the
//! child process can touch any path. Safety is delegated to the
//! Permission layer (deny/allow rules on the command string).
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::Deserialize;
use tokio::process::Command;
use crate::scoped_fs::ScopedFs;
const DESCRIPTION: &str = "Execute a shell command via bash. Supports the \
full shell pipes, redirects, command substitution, `&&`/`||`. Each call \
runs in a fresh shell rooted at the workspace; chain `cd <subdir> && cmd` \
when you need to operate elsewhere. stdout and stderr are merged. Default \
timeout 120s, max 600s.\n\n\
Output handling: when the command produces more than 80 lines (or ~12 KiB), \
the full output is saved to a file and only the LAST 80 lines are returned, \
prefixed with the saved path. The path is readable by Read; you can also \
inspect it from a follow-up Bash call (`grep ... <path>`, etc.).\n\n\
Prefer dedicated tools when one fits: Read instead of `cat`/`head`/`tail` \
on workspace files, Edit instead of `sed`/`awk` rewrites, Glob instead of \
`find <name>`, Grep instead of `grep`/`rg`. Reach for Bash when the task \
is shell-shaped: building, testing, version control, package management.";
const DEFAULT_TIMEOUT_SECS: u64 = 120;
const MAX_TIMEOUT_SECS: u64 = 600;
/// Number of trailing lines returned when output spills to a file.
const TAIL_LINES: usize = 80;
/// Inline-return budget. Outputs at or below this are returned in full;
/// above it triggers the spill-to-file path. Sized to leave headroom under
/// the Worker's 16 KiB default `ToolOutputLimits` cap so the inline path
/// reliably reaches the model intact.
const INLINE_BYTE_BUDGET: usize = 12 * 1024;
/// Maximum bytes loaded into memory from the spilled output file. The
/// file itself can be arbitrarily large; we only ever read the tail end
/// since that is what we return.
const TAIL_READ_BUDGET: usize = 256 * 1024;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct BashParams {
/// Shell command to execute. Passed verbatim to `bash -c`.
pub command: String,
/// Timeout in seconds. Defaults to 120, capped at 600.
#[serde(default)]
pub timeout: Option<u64>,
}
pub(crate) struct BashTool {
/// Workspace root that every invocation starts in. Snapshot of
/// `ScopedFs::pwd()` at registration time; never mutated, since we
/// don't track `cd` across calls.
cwd: PathBuf,
/// Directory to spill long outputs into. Caller is expected to have
/// added this path to the readable scope so the agent can Read the
/// saved files. The directory itself is created lazily.
output_dir: PathBuf,
/// Files we left on disk for follow-up inspection. Cleaned up on
/// `Drop` (= session end). `std::sync::Mutex` because access is
/// always synchronous and very brief.
spilled_outputs: std::sync::Mutex<Vec<PathBuf>>,
}
impl Drop for BashTool {
fn drop(&mut self) {
if let Ok(mut paths) = self.spilled_outputs.lock() {
for p in paths.drain(..) {
let _ = std::fs::remove_file(&p);
}
}
}
}
#[async_trait]
impl Tool for BashTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: BashParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid Bash input: {e}")))?;
let timeout_secs = params
.timeout
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(1, MAX_TIMEOUT_SECS);
// Persistent output file in the caller-supplied directory.
// `keep()` opts out of auto-delete so the agent can inspect the
// full output later; cleanup is deferred to `Drop` on this tool.
std::fs::create_dir_all(&self.output_dir).map_err(|e| {
ToolError::Internal(format!(
"create bash output dir {}: {e}",
self.output_dir.display()
))
})?;
let output_path: PathBuf = tempfile::Builder::new()
.prefix("bash-")
.suffix(".log")
.tempfile_in(&self.output_dir)
.map_err(|e| ToolError::Internal(format!("output tempfile: {e}")))?
.into_temp_path()
.keep()
.map_err(|e| ToolError::Internal(format!("persist output tempfile: {e}")))?;
let output_path_str = output_path
.to_str()
.ok_or_else(|| ToolError::Internal("output path is not UTF-8".into()))?;
// Wrapper:
// exec >file 2>&1 redirect stdout/stderr to the output file
// { user_cmd } run in a brace group (no subshell, so any
// `cd` inside still affects $? capture below)
// __exit=$? preserve the user command's exit code…
// wait 2>/dev/null …since `wait` clobbers $?. Reaping bg jobs
// guarantees the output file's writers all
// close before bash itself exits.
// exit $__exit propagate the user's exit
let wrapped = format!(
"exec >{out} 2>&1\n{{ {user_cmd}\n}}\n__insomnia_exit=$?\nwait 2>/dev/null\nexit $__insomnia_exit\n",
out = shell_single_quote(output_path_str),
user_cmd = params.command,
);
tracing::debug!(cmd = %params.command, cwd = %self.cwd.display(), timeout_secs, "Bash");
let mut child = Command::new("bash")
.arg("-c")
.arg(&wrapped)
.current_dir(&self.cwd)
.stdin(Stdio::null())
.stdout(Stdio::null()) // bash inherits — but the wrapper redirected via `exec`
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.map_err(|e| {
let _ = std::fs::remove_file(&output_path);
ToolError::ExecutionFailed(format!("spawn bash: {e}"))
})?;
let timeout_dur = Duration::from_secs(timeout_secs);
let wait_result = tokio::time::timeout(timeout_dur, child.wait()).await;
let (status, timed_out) = match wait_result {
Ok(Ok(s)) => (Some(s), false),
Ok(Err(e)) => {
let _ = std::fs::remove_file(&output_path);
return Err(ToolError::ExecutionFailed(format!("bash wait: {e}")));
}
Err(_) => (None, true),
};
// Inspect the on-disk output: total size first, tail bytes second.
let total_bytes = std::fs::metadata(&output_path)
.map(|m| m.len() as usize)
.unwrap_or(0);
let tail_bytes = read_tail_bytes(&output_path, TAIL_READ_BUDGET).unwrap_or_default();
let tail_text = String::from_utf8_lossy(&tail_bytes).into_owned();
let cmd_summary = truncate_for_summary(&params.command);
if timed_out {
// Preserve the partial output file — even cut-short logs help
// diagnose hangs.
let content = if total_bytes > 0 {
let last = take_last_n_lines(&tail_text, TAIL_LINES);
self.remember_spilled(&output_path);
Some(format!(
"[partial output before timeout — full at {}]\n{last}",
output_path.display()
))
} else {
let _ = std::fs::remove_file(&output_path);
None
};
return Ok(ToolOutput {
summary: format!("$ {cmd_summary} (timed out after {timeout_secs}s)"),
content,
});
}
let status = status.expect("status set on the success branch");
let summary = match status.code() {
Some(0) => format!("$ {cmd_summary}"),
Some(c) => format!("$ {cmd_summary} (exit {c})"),
None => format!("$ {cmd_summary} (terminated by signal)"),
};
if total_bytes == 0 {
let _ = std::fs::remove_file(&output_path);
return Ok(ToolOutput {
summary,
content: None,
});
}
// Inline if the whole output fits in our tail-read window AND is
// small enough to ride under the Worker's default cap.
let line_count = tail_text.lines().count();
let fully_loaded = total_bytes <= tail_bytes.len();
let fits_inline =
fully_loaded && total_bytes <= INLINE_BYTE_BUDGET && line_count <= TAIL_LINES;
let content = if fits_inline {
let _ = std::fs::remove_file(&output_path);
Some(tail_text)
} else {
let last = take_last_n_lines(&tail_text, TAIL_LINES);
// When `fully_loaded` we know the exact line count; otherwise
// the file is bigger than our read window so we report bytes
// and an "approximate" disclaimer.
let header = if fully_loaded {
format!(
"[showing last {TAIL_LINES} of {line_count} lines — full output ({total_bytes} bytes) at {}]",
output_path.display()
)
} else {
format!(
"[showing last {TAIL_LINES} lines (tail of {total_bytes}-byte output) — full at {}]",
output_path.display()
)
};
self.remember_spilled(&output_path);
Some(format!("{header}\n{last}"))
};
Ok(ToolOutput { summary, content })
}
}
impl BashTool {
fn remember_spilled(&self, path: &Path) {
if let Ok(mut v) = self.spilled_outputs.lock() {
v.push(path.to_path_buf());
}
}
}
/// Read up to `max_bytes` from the end of `path`. If the file is smaller
/// than `max_bytes`, the entire file is returned.
fn read_tail_bytes(path: &Path, max_bytes: usize) -> std::io::Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path)?;
let len = f.seek(SeekFrom::End(0))?;
let start = if len > max_bytes as u64 {
len - max_bytes as u64
} else {
0
};
f.seek(SeekFrom::Start(start))?;
let mut buf = Vec::with_capacity((len - start) as usize);
f.read_to_end(&mut buf)?;
Ok(buf)
}
/// Return the last `n` lines of `text`. If `text` has `n` or fewer lines
/// (per [`str::lines`]), the input is returned as-is (no allocation).
fn take_last_n_lines(text: &str, n: usize) -> String {
if text.is_empty() {
return String::new();
}
let total = text.lines().count();
if total <= n {
return text.to_owned();
}
let skip = total - n;
let mut count = 0usize;
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
count += 1;
if count == skip {
return text[i + 1..].to_owned();
}
}
}
text.to_owned()
}
fn truncate_for_summary(command: &str) -> String {
let one_line = command.lines().next().unwrap_or("");
let mut chars = one_line.chars();
let head: String = chars.by_ref().take(80).collect();
if chars.next().is_some() {
let mut shortened = head;
while shortened.chars().count() > 77 {
shortened.pop();
}
shortened.push_str("...");
shortened
} else {
head
}
}
/// Wrap a string in single quotes for safe inclusion in a bash command.
fn shell_single_quote(s: &str) -> String {
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
/// Factory for the `Bash` tool.
///
/// `output_dir` is where long outputs spill to; the caller is responsible
/// for arranging that the path is in the agent's readable scope. Every
/// invocation starts at `fs.pwd()` — the tool is intentionally stateless
/// w.r.t. the working directory.
pub fn bash_tool(fs: ScopedFs, output_dir: PathBuf) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(BashParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("Bash")
.description(DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(BashTool {
cwd: fs.pwd().to_path_buf(),
output_dir: output_dir.clone(),
spilled_outputs: std::sync::Mutex::new(Vec::new()),
});
(meta, tool)
})
}
#[cfg(test)]
mod tests {
use super::*;
use manifest::Scope;
use tempfile::TempDir;
/// Test harness: workspace tempdir + a separate spill tempdir kept
/// alive for the test's lifetime. The spill dir is added to the
/// scope as readable so callers exercise the production path.
struct Harness {
_workspace: TempDir,
spill: TempDir,
fs: ScopedFs,
}
fn setup() -> Harness {
let workspace = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let base = Scope::writable(workspace.path()).unwrap();
let mut config = manifest::ScopeConfig {
allow: base.allow_rules(),
deny: base.deny_rules(),
};
config.allow.push(manifest::ScopeRule {
target: spill.path().to_path_buf(),
permission: manifest::Permission::Read,
recursive: true,
});
let scope = Scope::from_config(&config).unwrap();
let fs = ScopedFs::new(scope, workspace.path().to_path_buf());
Harness {
_workspace: workspace,
spill,
fs,
}
}
fn make_tool(h: &Harness) -> Arc<dyn Tool> {
let def = bash_tool(h.fs.clone(), h.spill.path().to_path_buf());
let (_, tool) = def();
tool
}
#[tokio::test]
async fn runs_simple_command() {
let h = setup();
let def = bash_tool(h.fs.clone(), h.spill.path().to_path_buf());
let (meta, tool) = def();
assert_eq!(meta.name, "Bash");
let inp = serde_json::json!({ "command": "echo hello" });
let out = tool.execute(&inp.to_string()).await.unwrap();
assert_eq!(out.summary, "$ echo hello");
assert_eq!(out.content.as_deref().map(str::trim), Some("hello"));
}
#[tokio::test]
async fn merges_stdout_and_stderr() {
let h = setup();
let tool = make_tool(&h);
let inp = serde_json::json!({
"command": "echo out; echo err 1>&2",
});
let out = tool.execute(&inp.to_string()).await.unwrap();
let body = out.content.unwrap();
assert!(body.contains("out"));
assert!(body.contains("err"));
}
#[tokio::test]
async fn nonzero_exit_is_reported() {
let h = setup();
let tool = make_tool(&h);
let inp = serde_json::json!({ "command": "exit 7" });
let out = tool.execute(&inp.to_string()).await.unwrap();
assert!(out.summary.contains("exit 7"), "summary: {}", out.summary);
assert!(
out.content.is_none(),
"no output expected, got {:?}",
out.content
);
}
#[tokio::test]
async fn cd_does_not_persist_across_calls() {
// Stateless: a `cd` in one call must NOT leak into the next.
let h = setup();
let sub = h._workspace.path().join("nested");
std::fs::create_dir(&sub).unwrap();
let tool = make_tool(&h);
tool.execute(
&serde_json::json!({
"command": format!("cd {}", sub.to_str().unwrap()),
})
.to_string(),
)
.await
.unwrap();
let pwd_out = tool
.execute(&serde_json::json!({ "command": "pwd" }).to_string())
.await
.unwrap();
let body = pwd_out.content.unwrap();
let actual = std::fs::canonicalize(body.trim()).unwrap();
let workspace = std::fs::canonicalize(h._workspace.path()).unwrap();
assert_eq!(
actual, workspace,
"second call should start at workspace root, not the previous cd target"
);
}
#[tokio::test]
async fn timeout_kills_long_command() {
let h = setup();
let tool = make_tool(&h);
let inp = serde_json::json!({
"command": "sleep 30",
"timeout": 1,
});
let out = tool.execute(&inp.to_string()).await.unwrap();
assert!(
out.summary.contains("timed out"),
"summary: {}",
out.summary
);
}
#[tokio::test]
async fn invalid_json_is_invalid_argument() {
let h = setup();
let tool = make_tool(&h);
let err = tool.execute("not json").await.unwrap_err();
assert!(matches!(err, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn long_output_spills_and_returns_tail() {
let h = setup();
let spill_dir = h.spill.path().to_path_buf();
let tool = make_tool(&h);
// 200 lines: "line 1" .. "line 200". Tail of 80 keeps lines 121-200.
let inp = serde_json::json!({
"command": "for i in $(seq 1 200); do echo line $i; done",
});
let out = tool.execute(&inp.to_string()).await.unwrap();
let body = out.content.expect("expected content");
assert!(
body.contains(&format!("showing last {TAIL_LINES} of 200 lines")),
"tail header missing in: {}",
&body[..body.len().min(300)]
);
assert!(
body.contains(spill_dir.to_str().unwrap()),
"spill dir path missing: {body}"
);
// Last 80 lines are 121..200.
assert!(body.contains("\nline 200\n"));
assert!(body.contains("\nline 121\n"));
// line 120 is the last *elided* line.
assert!(!body.contains("\nline 120\n"), "elided line leaked: {body}");
}
#[tokio::test]
async fn wide_short_output_still_spills_when_byte_budget_exceeded() {
let h = setup();
let spill_dir = h.spill.path().to_path_buf();
let tool = make_tool(&h);
// One single line of ~20 KiB (over INLINE_BYTE_BUDGET = 12 KiB).
let inp = serde_json::json!({
"command": "printf 'x%.0s' {1..20480}",
});
let out = tool.execute(&inp.to_string()).await.unwrap();
let body = out.content.unwrap();
assert!(
body.contains(spill_dir.to_str().unwrap()),
"expected spill marker in: {}",
&body[..body.len().min(200)]
);
}
#[tokio::test]
async fn background_job_does_not_hang() {
let h = setup();
let tool = make_tool(&h);
// The wrapper's `wait` ensures we don't hang on a stray bg pipe.
let inp = serde_json::json!({
"command": "(sleep 0.05; echo bg) &",
"timeout": 5,
});
let out = tool.execute(&inp.to_string()).await.unwrap();
assert!(
!out.summary.contains("timed out"),
"summary: {}",
out.summary
);
}
#[tokio::test]
async fn spilled_files_are_cleaned_up_on_drop() {
let h = setup();
let spill_dir = h.spill.path().to_path_buf();
let tool = make_tool(&h);
let inp = serde_json::json!({
"command": "for i in $(seq 1 200); do echo $i; done",
});
tool.execute(&inp.to_string()).await.unwrap();
// The spill dir should now contain exactly one bash-*.log file.
let files_before: Vec<_> = std::fs::read_dir(&spill_dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.path())
.collect();
assert_eq!(files_before.len(), 1, "expected one spilled file");
let path = files_before.into_iter().next().unwrap();
assert!(path.exists());
drop(tool);
// Drop runs synchronously; file should be gone.
assert!(
!path.exists(),
"spilled file should be cleaned up on drop: {path:?}"
);
}
}

View File

@ -1,8 +1,8 @@
//! Built-in tools for the Insomnia LLM agent. //! Built-in tools for the Insomnia LLM agent.
//! //!
//! Implements Read / Write / Edit / Glob / Grep on top of the `llm-worker` //! Implements Read / Write / Edit / Glob / Grep / Bash on top of the
//! `Tool` infrastructure. Filesystem access is mediated by two orthogonal //! `llm-worker` `Tool` infrastructure. Filesystem access is mediated by
//! concerns: //! two orthogonal concerns:
//! //!
//! - [`ScopedFs`] — pod-lifetime, expresses the write-block boundary for //! - [`ScopedFs`] — pod-lifetime, expresses the write-block boundary for
//! the current scope. Derived from the manifest and shareable across //! the current scope. Derived from the manifest and shareable across
@ -13,17 +13,23 @@
//! //!
//! The Pod layer owns both instances and passes them to //! The Pod layer owns both instances and passes them to
//! [`builtin_tools`] when registering tools on a `Worker`. //! [`builtin_tools`] when registering tools on a `Worker`.
//!
//! `Bash` is the lone exception — its child processes bypass `ScopedFs`
//! entirely. Safety for arbitrary command execution is delegated to the
//! Permission layer (deny/allow rules on the command string).
pub mod error; pub mod error;
pub mod scoped_fs; pub mod scoped_fs;
pub mod tracker; pub mod tracker;
mod bash;
mod edit; mod edit;
mod glob; mod glob;
mod grep; mod grep;
mod read; mod read;
mod write; mod write;
pub use bash::bash_tool;
pub use edit::edit_tool; pub use edit::edit_tool;
pub use error::ToolsError; pub use error::ToolsError;
pub use glob::glob_tool; pub use glob::glob_tool;
@ -39,12 +45,22 @@ pub use write::write_tool;
/// All returned factories share the same tracker instance so that /// All returned factories share the same tracker instance so that
/// `Read` / `Write` / `Edit` see a consistent history across tool /// `Read` / `Write` / `Edit` see a consistent history across tool
/// invocations within a single session. /// invocations within a single session.
pub fn builtin_tools(fs: ScopedFs, tracker: Tracker) -> Vec<llm_worker::tool::ToolDefinition> { ///
/// `bash_output_dir` is where the Bash tool spills long outputs. The
/// caller is responsible for adding that path to the readable scope
/// (see [`manifest::Scope::with_extra_read`]) so the agent can `Read`
/// the saved files.
pub fn builtin_tools(
fs: ScopedFs,
tracker: Tracker,
bash_output_dir: std::path::PathBuf,
) -> Vec<llm_worker::tool::ToolDefinition> {
vec![ vec![
read_tool(fs.clone(), tracker.clone()), read_tool(fs.clone(), tracker.clone()),
write_tool(fs.clone(), tracker.clone()), write_tool(fs.clone(), tracker.clone()),
edit_tool(fs.clone(), tracker.clone()), edit_tool(fs.clone(), tracker),
glob_tool(fs.clone()), glob_tool(fs.clone()),
grep_tool(fs), grep_tool(fs.clone()),
bash_tool(fs, bash_output_dir),
] ]
} }

View File

@ -31,7 +31,8 @@
//! let scope = Scope::writable("/workspace").unwrap(); //! let scope = Scope::writable("/workspace").unwrap();
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime //! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime
//! let tracker = Tracker::new(); // session lifetime //! let tracker = Tracker::new(); // session lifetime
//! let defs = builtin_tools(fs, tracker); //! let bash_outputs = PathBuf::from("/run/insomnia/bash-output");
//! let defs = builtin_tools(fs, tracker, bash_outputs);
//! ``` //! ```
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};

View File

@ -3,7 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use llm_worker::tool::{Tool, ToolDefinition}; use llm_worker::tool::{Tool, ToolDefinition};
use manifest::Scope; use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
use serde_json::json; use serde_json::json;
use tempfile::TempDir; use tempfile::TempDir;
use tools::{ScopedFs, Tracker, builtin_tools}; use tools::{ScopedFs, Tracker, builtin_tools};
@ -27,19 +27,29 @@ impl Registry {
} }
} }
fn setup() -> (TempDir, Registry) { fn setup() -> (TempDir, TempDir, Registry) {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let fs = ScopedFs::new( let spill = TempDir::new().unwrap();
Scope::writable(dir.path()).unwrap(), let base = Scope::writable(dir.path()).unwrap();
dir.path().to_path_buf(), let mut config = ScopeConfig {
); allow: base.allow_rules(),
deny: base.deny_rules(),
};
config.allow.push(ScopeRule {
target: spill.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
});
let scope = Scope::from_config(&config).unwrap();
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
let tracker = Tracker::new(); let tracker = Tracker::new();
(dir, Registry::new(builtin_tools(fs, tracker))) let reg = Registry::new(builtin_tools(fs, tracker, spill.path().to_path_buf()));
(dir, spill, reg)
} }
#[tokio::test] #[tokio::test]
async fn unicode_path_and_content() { async fn unicode_path_and_content() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("日本語ファイル.txt"); let file = dir.path().join("日本語ファイル.txt");
let content = "こんにちは 🦀 世界\nabc\n"; let content = "こんにちは 🦀 世界\nabc\n";
@ -70,7 +80,7 @@ async fn unicode_path_and_content() {
async fn symlink_to_outside_scope_is_rejected_for_write() { async fn symlink_to_outside_scope_is_rejected_for_write() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let outside = TempDir::new().unwrap(); let outside = TempDir::new().unwrap();
let outside_target = outside.path().join("secret.txt"); let outside_target = outside.path().join("secret.txt");
std::fs::write(&outside_target, "secret").unwrap(); std::fs::write(&outside_target, "secret").unwrap();
@ -114,7 +124,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
#[tokio::test] #[tokio::test]
async fn empty_file_read_and_edit() { async fn empty_file_read_and_edit() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("empty.txt"); let file = dir.path().join("empty.txt");
std::fs::write(&file, "").unwrap(); std::fs::write(&file, "").unwrap();
@ -144,7 +154,7 @@ async fn empty_file_read_and_edit() {
#[tokio::test] #[tokio::test]
async fn very_long_single_line() { async fn very_long_single_line() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("long.txt"); let file = dir.path().join("long.txt");
let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines
std::fs::write(&file, &big).unwrap(); std::fs::write(&file, &big).unwrap();
@ -160,7 +170,7 @@ async fn very_long_single_line() {
#[tokio::test] #[tokio::test]
async fn relative_path_is_rejected() { async fn relative_path_is_rejected() {
let (_dir, reg) = setup(); let (_dir, _spill, reg) = setup();
let read = reg.get("Read"); let read = reg.get("Read");
let err = read let err = read
.execute(&json!({ "file_path": "relative.txt" }).to_string()) .execute(&json!({ "file_path": "relative.txt" }).to_string())
@ -171,7 +181,7 @@ async fn relative_path_is_rejected() {
#[tokio::test] #[tokio::test]
async fn directory_target_is_rejected_for_read() { async fn directory_target_is_rejected_for_read() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let read = reg.get("Read"); let read = reg.get("Read");
let err = read let err = read
.execute(&json!({ "file_path": dir.path().to_str().unwrap() }).to_string()) .execute(&json!({ "file_path": dir.path().to_str().unwrap() }).to_string())
@ -182,7 +192,7 @@ async fn directory_target_is_rejected_for_read() {
#[tokio::test] #[tokio::test]
async fn deeply_nested_new_file_is_created() { async fn deeply_nested_new_file_is_created() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let deep = dir.path().join("a/b/c/d/e/deep.txt"); let deep = dir.path().join("a/b/c/d/e/deep.txt");
let write = reg.get("Write"); let write = reg.get("Write");
write write
@ -200,7 +210,7 @@ async fn deeply_nested_new_file_is_created() {
#[tokio::test] #[tokio::test]
async fn replace_preserves_unicode() { async fn replace_preserves_unicode() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("u.txt"); let file = dir.path().join("u.txt");
std::fs::write(&file, "🦀 rust 🦀\n").unwrap(); std::fs::write(&file, "🦀 rust 🦀\n").unwrap();
@ -225,7 +235,7 @@ async fn replace_preserves_unicode() {
#[tokio::test] #[tokio::test]
async fn grep_handles_unicode_pattern() { async fn grep_handles_unicode_pattern() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("u.txt"); let file = dir.path().join("u.txt");
std::fs::write(&file, "English\n日本語\nрусский\n").unwrap(); std::fs::write(&file, "English\n日本語\nрусский\n").unwrap();

View File

@ -8,11 +8,25 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use llm_worker::tool::{Tool, ToolDefinition, ToolMeta}; use llm_worker::tool::{Tool, ToolDefinition, ToolMeta};
use manifest::Scope; use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
use serde_json::json; use serde_json::json;
use tempfile::TempDir; use tempfile::TempDir;
use tools::{ScopedFs, Tracker, builtin_tools}; use tools::{ScopedFs, Tracker, builtin_tools};
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
let base = Scope::writable(workspace).unwrap();
let mut config = ScopeConfig {
allow: base.allow_rules(),
deny: base.deny_rules(),
};
config.allow.push(ScopeRule {
target: spill.to_path_buf(),
permission: Permission::Read,
recursive: true,
});
Scope::from_config(&config).unwrap()
}
struct Registry { struct Registry {
entries: Vec<(ToolMeta, Arc<dyn Tool>)>, entries: Vec<(ToolMeta, Arc<dyn Tool>)>,
} }
@ -36,15 +50,14 @@ impl Registry {
} }
} }
fn setup() -> (TempDir, Registry) { fn setup() -> (TempDir, TempDir, Registry) {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let fs = ScopedFs::new( let spill = TempDir::new().unwrap();
Scope::writable(dir.path()).unwrap(), let scope = scope_with_spill(dir.path(), spill.path());
dir.path().to_path_buf(), let fs = ScopedFs::new(scope, dir.path().to_path_buf());
);
let tracker = Tracker::new(); let tracker = Tracker::new();
let reg = Registry::new(builtin_tools(fs, tracker)); let reg = Registry::new(builtin_tools(fs, tracker, spill.path().to_path_buf()));
(dir, reg) (dir, spill, reg)
} }
async fn call(tool: &Arc<dyn Tool>, input: serde_json::Value) -> llm_worker::tool::ToolOutput { async fn call(tool: &Arc<dyn Tool>, input: serde_json::Value) -> llm_worker::tool::ToolOutput {
@ -60,16 +73,16 @@ async fn call_err(tool: &Arc<dyn Tool>, input: serde_json::Value) -> llm_worker:
} }
#[test] #[test]
fn builtin_tools_registers_all_five() { fn builtin_tools_registers_full_set() {
let (_dir, reg) = setup(); let (_dir, _spill, reg) = setup();
let mut names = reg.names(); let mut names = reg.names();
names.sort(); names.sort();
assert_eq!(names, vec!["Edit", "Glob", "Grep", "Read", "Write"]); assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
} }
#[test] #[test]
fn meta_has_description_and_schema() { fn meta_has_description_and_schema() {
let (_dir, reg) = setup(); let (_dir, _spill, reg) = setup();
for (meta, _) in &reg.entries { for (meta, _) in &reg.entries {
assert!( assert!(
!meta.description.is_empty(), !meta.description.is_empty(),
@ -87,7 +100,7 @@ fn meta_has_description_and_schema() {
#[tokio::test] #[tokio::test]
async fn read_then_edit_then_read_roundtrip() { async fn read_then_edit_then_read_roundtrip() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("a.txt"); let file = dir.path().join("a.txt");
std::fs::write(&file, "hello world\n").unwrap(); std::fs::write(&file, "hello world\n").unwrap();
let p = file.to_str().unwrap(); let p = file.to_str().unwrap();
@ -119,7 +132,7 @@ async fn read_then_edit_then_read_roundtrip() {
#[tokio::test] #[tokio::test]
async fn write_then_grep_finds_content() { async fn write_then_grep_finds_content() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let write = reg.get("Write"); let write = reg.get("Write");
let grep = reg.get("Grep"); let grep = reg.get("Grep");
@ -148,7 +161,7 @@ async fn write_then_grep_finds_content() {
#[tokio::test] #[tokio::test]
async fn glob_finds_written_files() { async fn glob_finds_written_files() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let write = reg.get("Write"); let write = reg.get("Write");
let glob = reg.get("Glob"); let glob = reg.get("Glob");
@ -172,7 +185,7 @@ async fn glob_finds_written_files() {
#[tokio::test] #[tokio::test]
async fn out_of_scope_write_is_rejected() { async fn out_of_scope_write_is_rejected() {
let (_dir, reg) = setup(); let (_dir, _spill, reg) = setup();
let outside = TempDir::new().unwrap(); let outside = TempDir::new().unwrap();
let write = reg.get("Write"); let write = reg.get("Write");
@ -191,7 +204,7 @@ async fn out_of_scope_write_is_rejected() {
#[tokio::test] #[tokio::test]
async fn write_to_existing_without_read_fails() { async fn write_to_existing_without_read_fails() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("exists.txt"); let file = dir.path().join("exists.txt");
std::fs::write(&file, "preexisting").unwrap(); std::fs::write(&file, "preexisting").unwrap();
@ -212,7 +225,7 @@ async fn write_to_existing_without_read_fails() {
async fn shared_scoped_fs_across_tools() { async fn shared_scoped_fs_across_tools() {
// The key invariant: all builtin tools share the same ScopedFs instance, // The key invariant: all builtin tools share the same ScopedFs instance,
// so read-history set by Read is visible to Edit and Write. // so read-history set by Read is visible to Edit and Write.
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("shared.txt"); let file = dir.path().join("shared.txt");
std::fs::write(&file, "one\n").unwrap(); std::fs::write(&file, "one\n").unwrap();
@ -235,7 +248,7 @@ async fn shared_scoped_fs_across_tools() {
#[tokio::test] #[tokio::test]
async fn edit_requires_read_across_tools() { async fn edit_requires_read_across_tools() {
let (dir, reg) = setup(); let (dir, _spill, reg) = setup();
let file = dir.path().join("a.txt"); let file = dir.path().join("a.txt");
std::fs::write(&file, "foo\n").unwrap(); std::fs::write(&file, "foo\n").unwrap();
@ -256,17 +269,17 @@ async fn edit_requires_read_across_tools() {
#[tokio::test] #[tokio::test]
async fn deterministic_tool_order_is_registration_order() { async fn deterministic_tool_order_is_registration_order() {
let (_dir, reg) = setup(); let (_dir, _spill, reg) = setup();
// Registration order from builtin_tools(): Read, Write, Edit, Glob, Grep // Registration order from builtin_tools(): Read, Write, Edit, Glob, Grep, Bash
let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect(); let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
assert_eq!(names, vec!["Read", "Write", "Edit", "Glob", "Grep"]); assert_eq!(names, vec!["Read", "Write", "Edit", "Glob", "Grep", "Bash"]);
} }
// Regression: tool name capitalization matches Claude Code reference // Regression: tool name capitalization matches Claude Code reference
#[test] #[test]
fn tool_names_match_reference_spec() { fn tool_names_match_reference_spec() {
let (_dir, reg) = setup(); let (_dir, _spill, reg) = setup();
for expected in ["Read", "Write", "Edit", "Glob", "Grep"] { for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
assert!( assert!(
reg.entries.iter().any(|(m, _)| m.name == expected), reg.entries.iter().any(|(m, _)| m.name == expected),
"missing tool {expected}" "missing tool {expected}"
@ -278,12 +291,11 @@ fn tool_names_match_reference_spec() {
async fn tracker_recent_files_tracks_read_write_edit() { async fn tracker_recent_files_tracks_read_write_edit() {
// Build a fresh registry that shares a tracker we can query afterwards. // Build a fresh registry that shares a tracker we can query afterwards.
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let fs = ScopedFs::new( let spill = TempDir::new().unwrap();
Scope::writable(dir.path()).unwrap(), let scope = scope_with_spill(dir.path(), spill.path());
dir.path().to_path_buf(), let fs = ScopedFs::new(scope, dir.path().to_path_buf());
);
let tracker = Tracker::new(); let tracker = Tracker::new();
let reg = Registry::new(builtin_tools(fs, tracker.clone())); let reg = Registry::new(builtin_tools(fs, tracker.clone(), spill.path().to_path_buf()));
let a = dir.path().join("a.txt"); let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt"); let b = dir.path().join("b.txt");
@ -324,5 +336,52 @@ async fn tracker_recent_files_tracks_read_write_edit() {
); );
} }
#[tokio::test]
async fn bash_inherits_scoped_fs_pwd() {
// The Bash tool starts at the ScopedFs's pwd. Without any `cd`, its
// `pwd` should canonicalize to the workspace root we set up.
let (dir, _spill, reg) = setup();
let bash = reg.get("Bash");
let out = call(&bash, json!({ "command": "pwd" })).await;
let body = out.content.unwrap();
let actual = std::fs::canonicalize(body.trim()).unwrap();
let expected = std::fs::canonicalize(dir.path()).unwrap();
assert_eq!(actual, expected);
}
#[tokio::test]
async fn bash_spilled_file_is_readable_via_read_tool() {
// Long Bash output spills to a path that the controller has added to
// the readable scope. The agent should be able to Read that path
// exactly like any in-scope file.
let (_dir, spill, reg) = setup();
let bash = reg.get("Bash");
let out = call(
&bash,
json!({ "command": "for i in $(seq 1 200); do echo line $i; done" }),
)
.await;
let body = out.content.unwrap();
let spill_str = spill.path().to_str().unwrap();
// Extract the spilled path from the marker line.
let marker = body.lines().next().unwrap();
let prefix_pos = marker
.find(spill_str)
.expect("marker should reference the spill dir");
let path_end_rel = marker[prefix_pos..]
.find(".log")
.expect("marker should end the path with .log");
let spilled = &marker[prefix_pos..prefix_pos + path_end_rel + 4];
// Read the file via the Read tool — must succeed (in scope).
let read_out = call(&reg.get("Read"), json!({ "file_path": spilled })).await;
let read_body = read_out.content.expect("Read returned content");
// The full 200 lines should be in the saved file even though Bash
// returned only the tail of 80.
assert!(read_body.contains("line 1\n"), "missing line 1: {read_body}");
assert!(read_body.contains("line 200"), "missing line 200");
}
// Sanity: unused Path import guard // Sanity: unused Path import guard
const _: fn() -> &'static Path = || Path::new("/"); const _: fn() -> &'static Path = || Path::new("/");

View File

@ -590,22 +590,31 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'static>> {
.add_modifier(Modifier::ITALIC), .add_modifier(Modifier::ITALIC),
); );
let summary_source: String = match &tc.state { // Body source: prefer the full output (e.g. Bash's stdout/stderr) so
// Detail mode can expose it. Fall back to the summary when the tool
// didn't emit any content.
let body_source: String = match &tc.state {
ToolCallState::Done {
output: Some(out), ..
}
| ToolCallState::Error {
output: Some(out), ..
} => out.clone(),
ToolCallState::Done { summary, .. } | ToolCallState::Error { summary, .. } => { ToolCallState::Done { summary, .. } | ToolCallState::Error { summary, .. } => {
summary.clone() summary.clone()
} }
_ => String::new(), _ => String::new(),
}; };
let summary_cap = match mode { let body_cap = match mode {
Mode::Normal => 3, Mode::Normal => 3,
Mode::Detail => usize::MAX, Mode::Detail => usize::MAX,
Mode::Overview => unreachable!(), Mode::Overview => unreachable!(),
}; };
if !summary_source.is_empty() { if !body_source.is_empty() {
emit_capped_lines( emit_capped_lines(
&mut lines, &mut lines,
&summary_source, &body_source,
summary_cap, body_cap,
Style::default().fg(Color::Gray), Style::default().fg(Color::Gray),
); );
} }

View File

@ -25,3 +25,8 @@ Bash の子プロセスは ScopedFs を経由しない。Scope による保護
## 依存チケット ## 依存チケット
- [permission-extension-point.md](permission-extension-point.md) — deny/allow ルールによる Bash コマンド制御 - [permission-extension-point.md](permission-extension-point.md) — deny/allow ルールによる Bash コマンド制御
## Review
- 状態: Approve with follow-up
- レビュー詳細: [./bash-tool.review.md](./bash-tool.review.md)
- 日付: 2026-05-01