Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,184 changes: 1,177 additions & 7 deletions Cargo.lock

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions crates/tinytools-agent/src/repair/test/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,32 @@ fn truly_unrecoverable_input_is_none() {
"an open string cannot be guessed"
);
}

#[test]
fn strip_trailing_commas_keeps_layout_and_multibyte_text_intact() {
// Whitespace after the dropped comma survives, so indentation is preserved.
assert_eq!(
strip_trailing_commas("{ \"a\": 1, \"b\": [1, 2,\n] ,\n}"),
"{ \"a\": 1, \"b\": [1, 2\n] \n}"
);
// Input with nothing to strip comes back byte-for-byte, including
// non-ASCII text inside and outside strings.
let src = "{\"name\": \"café 日本 🦀\", \"tags\": [\"é\", \"ü\"]}";
assert_eq!(strip_trailing_commas(src), src);
// A comma inside a string is data, and non-ASCII neighbours do not
// disturb the one that is stripped.
assert_eq!(
strip_trailing_commas("{\"a\": \"é,}\", \"b\": \"日本\",}"),
"{\"a\": \"é,}\", \"b\": \"日本\"}"
);
}

#[test]
fn strip_trailing_commas_handles_nested_arrays_and_objects_and_string_commas() {
assert_eq!(
strip_trailing_commas("{ \"a\": 1, \"b\": [1, 2,], }"),
"{ \"a\": 1, \"b\": [1, 2] }"
);
let src = "{ \"reason\": \"a, b, c,\" }";
assert_eq!(strip_trailing_commas(src), src);
}
14 changes: 12 additions & 2 deletions crates/tinytools-std/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Host-independent building blocks for agent tools: cross-agent file staleness tracking, SSRF-safe URL validation, and PATH probing."
description = "Host-independent building blocks for agent tools: filesystem, git and network tools behind host gates, cross-agent file staleness tracking, SSRF-safe URL validation, and PATH probing."
documentation = "https://docs.rs/tinytools-std"
readme = "README.md"
publish = false

[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
# `image_info` base64 data URIs.
base64 = "0.22"
# `curl` streams the response body chunk by chunk to disk.
futures-util = { version = "0.3", default-features = false }
# Advisory inter-process `flock` around the MEMORY.md read-modify-write.
fs2 = "0.4"
# `glob_search` pattern matching.
Expand All @@ -25,13 +29,19 @@ log = "0.4"
parking_lot = "0.12"
# `grep` pattern compilation.
regex = { workspace = true }
# The network tools' HTTP client. rustls only: a host that wants another TLS
# backend adds it to its own `reqwest` dependency and cargo unifies the features.
# `stream` drives `curl`'s chunked download; `multipart` builds the `pushover` form.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "multipart"] }
# Safe effective-credential executable checks for Unix PATH candidates.
rustix = { version = "1", default-features = false, features = ["fs"] }
serde_json = { workspace = true }
# `curl` reports the SHA-256 of what it downloaded.
sha2 = "0.10"
tinytools = { path = "../tinytools", version = "0.5.0" }
# `spawn_blocking` keeps synchronous system DNS resolution off async executor threads;
# `fs`/`process`/`time` serve the filesystem tools.
tokio = { version = "1", default-features = false, features = ["rt", "sync", "fs", "process", "time"] }
tokio = { version = "1", default-features = false, features = ["rt", "sync", "fs", "process", "time", "io-util"] }
tracing = { workspace = true }
# `grep` and `glob_search` directory walks.
walkdir = "2"
Expand Down
3 changes: 2 additions & 1 deletion crates/tinytools-std/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Host-independent building blocks for agent tools, extracted from OpenHuman.
| --- | --- |
| `file_state` | Process-wide read/write stamps so parallel agents detect stale or partial reads before overwriting a file. The host decides whether the guard is on (`init_global(enabled)`); read tools pass `record_read` an `Instant` captured before their I/O. |
| `url_guard` | URL validation with SSRF checks for outbound network tools. `validate_url_with_dns_check` returns a `ValidatedUrl` whose vetted `addrs` the caller must pin its HTTP client to (e.g. `reqwest`'s `resolve_to_addrs`); re-resolving the hostname reopens DNS rebinding. |
| `filesystem` | The file and repository tools: `file_read`, `file_write`, `edit_file`, `apply_patch`, `grep`, `glob`, `list_files`, `csv_export`, `read_diff`, `git_operations`, `run_linter`, `run_tests`, `update_memory_md`. Every tool that can touch a path takes an `Arc<dyn FsGate>`: the tool does the I/O, the host's gate (autonomy, workspace boundary, approvals, action budget) decides whether it may. |
| `filesystem` | The file and repository tools: `file_read`, `file_write`, `edit_file`, `apply_patch`, `grep`, `glob`, `list_files`, `csv_export`, `read_diff`, `git_operations`, `run_linter`, `run_tests`, `update_memory_md`, `image_info`, `read_workspace_state`. Every tool that can touch a path takes an `Arc<dyn FsGate>`: the tool does the I/O, the host's gate (autonomy, workspace boundary, approvals, action budget) decides whether it may. |
| `network` | The network tools: `http_request`, `web_fetch`, `curl`, `pushover`. Every tool takes an `Arc<dyn NetGate>`: the tool does the I/O, the host's gate (autonomy, action budget, approval, privacy mode, proxy) decides whether it may and how. Two smaller seams keep host behavior out: `PaymentHook` answers a `402 Payment Required` for `http_request`, and `HtmlExtractor` converts pages to Markdown for `web_fetch`. Names, descriptions and schemas are pinned by `src/network/fixtures/`. |
| `detect_tools` | `find_on_path` and the read-only `detect_tools` tool. |

No enforcement of host policy lives here; the crate only supplies mechanisms. The `filesystem` tools' name, description and JSON Schema are pinned by the fixtures in `src/filesystem/fixtures/`.
9 changes: 8 additions & 1 deletion crates/tinytools-std/src/filesystem/contract_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fn tools() -> Vec<(&'static str, Box<dyn Tool>)> {
),
("glob_search", Box::new(GlobTool::new(gate.clone()))),
("grep", Box::new(GrepTool::new(gate.clone()))),
("image_info", Box::new(ImageInfoTool::new(gate.clone()))),
("list_files", Box::new(ListFilesTool::new(gate))),
("read_diff", Box::new(ReadDiffTool::new(workspace.clone()))),
(
Expand All @@ -43,7 +44,11 @@ fn tools() -> Vec<(&'static str, Box<dyn Tool>)> {
("run_tests", Box::new(RunTestsTool::new(workspace.clone()))),
(
"update_memory_md",
Box::new(UpdateMemoryMdTool::new(workspace)),
Box::new(UpdateMemoryMdTool::new(workspace.clone())),
),
(
"read_workspace_state",
Box::new(WorkspaceStateTool::new(workspace)),
),
]
}
Expand All @@ -68,11 +73,13 @@ fn fixture(name: &str) -> Value {
"git_operations" => include_str!("fixtures/git_operations.json"),
"glob_search" => include_str!("fixtures/glob_search.json"),
"grep" => include_str!("fixtures/grep.json"),
"image_info" => include_str!("fixtures/image_info.json"),
"list_files" => include_str!("fixtures/list_files.json"),
"read_diff" => include_str!("fixtures/read_diff.json"),
"run_linter" => include_str!("fixtures/run_linter.json"),
"run_tests" => include_str!("fixtures/run_tests.json"),
"update_memory_md" => include_str!("fixtures/update_memory_md.json"),
"read_workspace_state" => include_str!("fixtures/read_workspace_state.json"),
other => panic!("no fixture for {other}"),
};
serde_json::from_str(raw).expect("fixture is valid JSON")
Expand Down
22 changes: 22 additions & 0 deletions crates/tinytools-std/src/filesystem/fixtures/image_info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"description": "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data.",
"exposure": "Direct",
"name": "image_info",
"permission_level": "ReadOnly",
"schema": {
"properties": {
"include_base64": {
"description": "Include base64-encoded image data in output (default: false)",
"type": "boolean"
},
"path": {
"description": "Path to the image file (absolute or relative to workspace)",
"type": "string"
}
},
"required": [
"path"
],
"type": "object"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"description": "Get a read-only overview of the workspace: git status (modified/untracked files), recent commits, and top-level directory structure. Useful for understanding the current project state before planning tasks.",
"exposure": "Direct",
"name": "read_workspace_state",
"permission_level": "ReadOnly",
"schema": {
"properties": {
"include_tree": {
"default": true,
"description": "Include top-level directory tree (default: true).",
"type": "boolean"
},
"recent_commits": {
"default": 5,
"description": "Number of recent commits to show (default: 5).",
"type": "integer"
}
},
"type": "object"
}
}
16 changes: 8 additions & 8 deletions crates/tinytools-std/src/filesystem/git_operations/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use std::path::Path;
///
/// The entries are `section.key`, lowercased, with any subsection elided —
/// `remote.origin.url` is checked as `remote.url`.
pub(super) const ALLOWED_REPO_CONFIG: &[&str] = &[
pub(crate) const ALLOWED_REPO_CONFIG: &[&str] = &[
// What `git init` and `git clone` write, and nothing else.
//
// `core.worktree` is deliberately absent, unlike the read-only sibling
Expand Down Expand Up @@ -120,7 +120,7 @@ pub(super) const ALLOWED_REPO_CONFIG: &[&str] = &[
///
/// `core.hooksPath` and `commit.gpgSign` are handled separately, in
/// [`hardened_git`] — see there for why.
pub(super) const NEUTRALISED_CONFIG: &[&str] = &[
pub(crate) const NEUTRALISED_CONFIG: &[&str] = &[
"core.fsmonitor=",
"core.sshCommand=",
"core.pager=cat",
Expand Down Expand Up @@ -208,7 +208,7 @@ pub fn shell_git_env() -> std::collections::HashMap<std::ffi::OsString, std::ffi
/// `GIT_CONFIG_GLOBAL` must name something readable-and-empty rather than be
/// unset — unsetting it lets git fall back to `~/.gitconfig`, which is the
/// thing being suppressed. `/dev/null` is not a path on Windows; `NUL` is.
pub(super) const NULL_CONFIG_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
pub(crate) const NULL_CONFIG_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };

/// Close the system/global config files and the command-valued `GIT_*` env
/// vars on a `git` invocation, without touching anything about how it reads
Expand All @@ -221,7 +221,7 @@ pub(super) const NULL_CONFIG_PATH: &str = if cfg!(windows) { "NUL" } else { "/de
/// [`first_disallowed_repo_config_key`] — a separate step, precisely because
/// [`hardened_git`]'s `-c` layer below must not be present while that step is
/// reading what the repository itself set (see its own doc comment).
pub(super) fn suppress_ambient_git_config(
pub(crate) fn suppress_ambient_git_config(
cmd: &mut tokio::process::Command,
) -> &mut tokio::process::Command {
cmd.env("GIT_CONFIG_NOSYSTEM", "1")
Expand Down Expand Up @@ -274,7 +274,7 @@ pub(super) fn suppress_ambient_git_config(
/// sign. Overriding it here removes that decision from the repository
/// entirely, the same way `core.editor=false` removes commit message
/// editing from it in [`NEUTRALISED_CONFIG`] above.
pub(super) fn hardened_git(dir: &Path) -> tokio::process::Command {
pub(crate) fn hardened_git(dir: &Path) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("git");
suppress_ambient_git_config(&mut cmd).current_dir(dir);
for kv in NEUTRALISED_CONFIG {
Expand All @@ -293,7 +293,7 @@ pub(super) fn hardened_git(dir: &Path) -> tokio::process::Command {
/// `remote.origin.url` → `remote.url`; `core.filemode` → `core.filemode`. A
/// subsection may itself contain dots (`includeIf.gitdir:~/x.y/.path`), so the
/// first and last components are the reliable ones.
pub(super) fn normalise_config_key(key: &str) -> String {
pub(crate) fn normalise_config_key(key: &str) -> String {
let key = key.to_ascii_lowercase();
match (key.find('.'), key.rfind('.')) {
(Some(first), Some(last)) if first != last => {
Expand Down Expand Up @@ -339,7 +339,7 @@ pub(super) fn normalise_config_key(key: &str) -> String {
/// `run_git_command_in` and the repository probe in `execute_in_context`,
/// which reaches this conclusion without ever getting as far as the guard —
/// cannot word the same refusal differently.
pub(super) fn disallowed_config_refusal(dir: &Path, key: &str) -> String {
pub(crate) fn disallowed_config_refusal(dir: &Path, key: &str) -> String {
format!(
"refusing to run git in {}: its repository config sets `{key}`, which is \
not on the allowlist of configuration this tool will run under. \
Expand All @@ -350,7 +350,7 @@ pub(super) fn disallowed_config_refusal(dir: &Path, key: &str) -> String {
)
}

pub(super) async fn first_disallowed_repo_config_key(dir: &Path) -> anyhow::Result<Option<String>> {
pub(crate) async fn first_disallowed_repo_config_key(dir: &Path) -> anyhow::Result<Option<String>> {
let mut cmd = tokio::process::Command::new("git");
suppress_ambient_git_config(&mut cmd).current_dir(dir);
let output = cmd.args(["config", "--list", "--null"]).output().await?;
Expand Down
2 changes: 1 addition & 1 deletion crates/tinytools-std/src/filesystem/git_operations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::sync::Arc;
use tinytools::ToolRunContext;
use tinytools::{Tool, ToolCallOptions, ToolResult};

mod config;
pub(crate) mod config;
mod render;

pub use self::config::shell_git_env;
Expand Down
Loading
Loading