crates/gunnar-mcp/src/tools.rs
//! **The allowlist**: every tool this connector answers, its arguments, and
//! the ONE control-plane call each becomes.
//!
//! The list is fixed at compile time ([`TOOLS`]). A name that is not on it is
//! refused by name; a write tool is refused unless the connection holds the
//! write grant; an argument that is not declared is refused; and no argument
//! anywhere is an address: there is no URL, host, endpoint or command among
//! them, and a value that carries a scheme (`://`) is refused outright
//! ([`no_address`]). What the tool reaches is decided by its NAME, never by
//! what an agent puts in it.
use serde_json::{json, Map, Value};
/// What a connection may do. Read is the default; write is a separate,
/// explicit grant the person ticks at their passkey touch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
/// The read tools only.
Read,
/// The read tools and the write tools.
ReadWrite,
}
impl Scope {
/// The OAuth scope string.
pub fn as_str(self) -> &'static str {
match self {
Scope::Read => SCOPE_READ,
Scope::ReadWrite => "gunnar:read gunnar:write",
}
}
/// Does this scope cover `tool`?
pub fn allows(self, tool: &Tool) -> bool {
!tool.write || self == Scope::ReadWrite
}
}
/// The read scope.
pub const SCOPE_READ: &str = "gunnar:read";
/// The write scope: a separate grant, off unless the person grants it.
pub const SCOPE_WRITE: &str = "gunnar:write";
/// One argument of a tool.
#[derive(Debug)]
pub struct Arg {
/// Its name in `arguments`.
pub name: &'static str,
/// What kind of value it takes.
pub kind: Kind,
/// Must it be given?
pub required: bool,
/// What the agent is told it is.
pub about: &'static str,
}
/// The kind of value an argument takes. There is no kind for an address.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// A namespace or repository name: letters, digits, `.`, `_`, `-`.
Name,
/// A full hex object id (40 or 64 characters).
Oid,
/// A revision: a ref name (`refs/heads/main`, `main`, `HEAD`) or a hex oid.
Rev,
/// A path inside a repository's tree.
Path,
/// Free text: a search pattern, a filter, a comment.
Text,
/// A positive whole number.
Number,
/// A list of paths.
Paths,
/// One of a fixed set of words.
OneOf(&'static [&'static str]),
}
/// One tool on the allowlist.
#[derive(Debug)]
pub struct Tool {
/// The tool's name, as the agent calls it.
pub name: &'static str,
/// A short human title.
pub title: &'static str,
/// What it does, for the agent.
pub about: &'static str,
/// The one control-plane call it becomes (said in the handback and the docs).
pub call: &'static str,
/// True for a tool that changes something: listed and answered only under
/// the write grant.
pub write: bool,
/// Its arguments.
pub args: &'static [Arg],
}
const NAMESPACE: Arg = Arg {
name: "namespace",
kind: Kind::Name,
required: true,
about: "The repository's namespace (the part before the slash).",
};
const REPO: Arg = Arg {
name: "repo",
kind: Kind::Name,
required: true,
about: "The repository's name (the part after the slash).",
};
/// **The allowlist.** Each entry is one existing control-plane call.
pub const TOOLS: &[Tool] = &[
Tool {
name: "list_repositories",
title: "List repositories",
about: "List the repositories you can see on this Gunnar, with their visibility and default branch. Optionally filter by a part of `namespace/name`.",
call: "Repos.List",
write: false,
args: &[Arg { name: "query", kind: Kind::Text, required: false, about: "Only repositories whose `namespace/name` contains this text." }],
},
Tool {
name: "list_refs",
title: "List branches and tags",
about: "List a repository's branches and tags with the commit each points at.",
call: "Refs.List",
write: false,
args: &[NAMESPACE, REPO, Arg { name: "prefix", kind: Kind::OneOf(&["refs/heads/", "refs/tags/", ""]), required: false, about: "Only refs under this prefix: `refs/heads/` (branches), `refs/tags/` (tags), or empty for all." }],
},
Tool {
name: "list_commits",
title: "List commits",
about: "List commits, newest first, from a revision (default: the default branch), optionally only those that changed a path. Each commit names its tree oid, which `browse_tree` opens.",
call: "Refs.Log",
write: false,
args: &[
NAMESPACE,
REPO,
Arg { name: "rev", kind: Kind::Rev, required: false, about: "A branch, tag, `HEAD` or full commit oid. Empty: the default branch." },
Arg { name: "path", kind: Kind::Path, required: false, about: "Only commits that changed this file or directory." },
Arg { name: "limit", kind: Kind::Number, required: false, about: "How many commits (1 to 100, default 20)." },
],
},
Tool {
name: "browse_tree",
title: "Browse a tree",
about: "List one directory (a tree object) by its oid: each entry's name, kind and oid. Start from a commit's tree oid (from `list_commits`); open a sub-directory with its oid, and a file with `read_file`.",
call: "Refs.ReadObject",
write: false,
args: &[NAMESPACE, REPO, Arg { name: "oid", kind: Kind::Oid, required: true, about: "The tree's full hex oid." }],
},
Tool {
name: "read_file",
title: "Read a file",
about: "Read one file (a blob object) by its oid, as text. Large files are cut at 256 KiB, and a binary file is described, not shown.",
call: "Refs.ReadObject",
write: false,
args: &[NAMESPACE, REPO, Arg { name: "oid", kind: Kind::Oid, required: true, about: "The file's full hex blob oid (from `browse_tree`)." }],
},
Tool {
name: "search_code",
title: "Search code",
about: "Search one revision's files for a literal string. Answers the path, line number and line of each match.",
call: "Refs.Search",
write: false,
args: &[
NAMESPACE,
REPO,
Arg { name: "pattern", kind: Kind::Text, required: true, about: "The literal text to find." },
Arg { name: "rev", kind: Kind::Rev, required: false, about: "The revision to search. Empty: the default branch." },
],
},
Tool {
name: "diff",
title: "Compare two revisions",
about: "List the files that differ between two revisions, with the kind of change, the line counts and the old and new blob oids (read them with `read_file`).",
call: "Refs.Diff",
write: false,
args: &[
NAMESPACE,
REPO,
Arg { name: "from", kind: Kind::Rev, required: true, about: "The older revision (a ref name or oid)." },
Arg { name: "to", kind: Kind::Rev, required: true, about: "The newer revision (a ref name or oid)." },
Arg { name: "paths", kind: Kind::Paths, required: false, about: "Only changes under these path prefixes." },
],
},
Tool {
name: "list_issues",
title: "List issues",
about: "List a repository's issues: number, state, title and author.",
call: "Forge.ListIssues",
write: false,
args: &[NAMESPACE, REPO],
},
Tool {
name: "get_issue",
title: "Read an issue",
about: "Read one issue: its title, state, author and text.",
call: "Forge.GetIssue",
write: false,
args: &[NAMESPACE, REPO, Arg { name: "number", kind: Kind::Number, required: true, about: "The issue's number." }],
},
Tool {
name: "list_merge_requests",
title: "List merge requests",
about: "List a repository's merge requests: number, state, title, source and target branch.",
call: "PullRequests.List",
write: false,
args: &[NAMESPACE, REPO, Arg { name: "state", kind: Kind::OneOf(&["open", "merged", "closed", "all"]), required: false, about: "Only merge requests in this state (default: all)." }],
},
Tool {
name: "get_merge_request",
title: "Read a merge request",
about: "Read one merge request: title, state, branches, the commits they point at, and its description. Compare the two with `diff`.",
call: "PullRequests.Get",
write: false,
args: &[NAMESPACE, REPO, Arg { name: "number", kind: Kind::Number, required: true, about: "The merge request's number." }],
},
Tool {
name: "comment_on_issue",
title: "Comment on an issue",
about: "Add a comment to an issue, signed as you. Only with the write grant.",
call: "Forge.Append",
write: true,
args: &[
NAMESPACE,
REPO,
Arg { name: "number", kind: Kind::Number, required: true, about: "The issue's number." },
Arg { name: "body", kind: Kind::Text, required: true, about: "The comment's text." },
],
},
];
/// The tool named `name`, if it is on the allowlist.
pub fn tool(name: &str) -> Option<&'static Tool> {
TOOLS.iter().find(|t| t.name == name)
}
/// One tool call, typed: what the [`crate::Backend`] answers with ONE
/// control-plane call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Call {
/// `Repos.List`.
ListRepositories { query: String },
/// `Refs.List`.
ListRefs { repo: RepoName, prefix: String },
/// `Refs.Log`.
ListCommits {
repo: RepoName,
rev: String,
path: String,
limit: u32,
},
/// `Refs.ReadObject` of a tree.
BrowseTree { repo: RepoName, oid: String },
/// `Refs.ReadObject` of a blob.
ReadFile {
repo: RepoName,
oid: String,
max_bytes: u64,
},
/// `Refs.Search`.
SearchCode {
repo: RepoName,
pattern: String,
rev: String,
},
/// `Refs.Diff`.
Diff {
repo: RepoName,
from: String,
to: String,
paths: Vec<String>,
},
/// `Forge.ListIssues`.
ListIssues { repo: RepoName },
/// `Forge.GetIssue`.
GetIssue { repo: RepoName, number: u64 },
/// `PullRequests.List`; `state` is `open`, `merged`, `closed` or `all`.
ListMergeRequests { repo: RepoName, state: String },
/// `PullRequests.Get`.
GetMergeRequest { repo: RepoName, number: u64 },
/// `Forge.Append` of an issue comment (write grant only).
CommentOnIssue {
repo: RepoName,
number: u64,
body: String,
},
}
impl Call {
/// The repository the call is about, if any.
pub fn repo(&self) -> Option<&RepoName> {
match self {
Call::ListRepositories { .. } => None,
Call::ListRefs { repo, .. }
| Call::ListCommits { repo, .. }
| Call::BrowseTree { repo, .. }
| Call::ReadFile { repo, .. }
| Call::SearchCode { repo, .. }
| Call::Diff { repo, .. }
| Call::ListIssues { repo }
| Call::GetIssue { repo, .. }
| Call::ListMergeRequests { repo, .. }
| Call::GetMergeRequest { repo, .. }
| Call::CommentOnIssue { repo, .. } => Some(repo),
}
}
}
/// `namespace/repo`, both checked to be plain names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoName {
/// The namespace.
pub namespace: String,
/// The repository.
pub name: String,
}
impl std::fmt::Display for RepoName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.namespace, self.name)
}
}
/// The most a `read_file` answer carries.
pub const READ_FILE_MAX: u64 = 256 * 1024;
/// The most commits one `list_commits` answers.
pub const LOG_MAX: u32 = 100;
/// The longest free text an argument may carry.
const TEXT_MAX: usize = 4096;
/// The longest search pattern.
const PATTERN_MAX: usize = 256;
/// Why a call was not made.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refused {
/// The name is not on the allowlist (JSON-RPC -32602, "Unknown tool").
Unknown(String),
/// The tool writes and the connection holds no write grant.
NeedsWrite(&'static str),
/// An argument was missing, undeclared or malformed.
Argument(String),
}
impl Refused {
/// The sentence the agent is given.
pub fn words(&self) -> String {
match self {
Refused::Unknown(name) => format!("Unknown tool: {name}. This connector answers only its fixed list of tools."),
Refused::NeedsWrite(name) => format!("The tool {name} changes something, and this connection holds only the read grant. Connect again and grant write access to use it."),
Refused::Argument(why) => why.clone(),
}
}
}
/// A tool name, made safe to say back: printable ASCII, at most 64 characters.
pub fn said_name(name: &str) -> String {
let mut out: String = name
.chars()
.filter(|c| c.is_ascii_graphic())
.take(64)
.collect();
if out.is_empty() {
out.push_str("(empty)");
}
out
}
/// **Parse one `tools/call`** into a [`Call`], or say why not.
///
/// # Errors
///
/// [`Refused`]: an unknown name, a write tool without the write grant, or an
/// argument that is missing, undeclared, malformed or an address.
pub fn parse(name: &str, arguments: Option<&Value>, scope: Scope) -> Result<Call, Refused> {
let tool = tool(name).ok_or_else(|| Refused::Unknown(said_name(name)))?;
if !scope.allows(tool) {
return Err(Refused::NeedsWrite(tool.name));
}
let empty = Map::new();
let args = match arguments {
None | Some(Value::Null) => &empty,
Some(Value::Object(map)) => map,
Some(_) => {
return Err(Refused::Argument(
"`arguments` must be an object".to_owned(),
))
}
};
for key in args.keys() {
if !tool.args.iter().any(|a| a.name == key) {
return Err(Refused::Argument(format!(
"{} takes no argument named {:?}; it takes {}",
tool.name,
said_name(key),
tool.args
.iter()
.map(|a| a.name)
.collect::<Vec<_>>()
.join(", ")
)));
}
}
let got = Got { tool, args };
let repo = || -> Result<RepoName, Refused> {
Ok(RepoName {
namespace: got_str(tool, args, "namespace")?,
name: got_str(tool, args, "repo")?,
})
};
let call = match tool.name {
"list_repositories" => Call::ListRepositories {
query: got.text("query")?,
},
"list_refs" => Call::ListRefs {
repo: repo()?,
prefix: got.text("prefix")?,
},
"list_commits" => {
let limit = got.number("limit")?.unwrap_or(20);
if limit == 0 || limit > u64::from(LOG_MAX) {
return Err(Refused::Argument(format!(
"limit must be between 1 and {LOG_MAX}"
)));
}
Call::ListCommits {
repo: repo()?,
rev: got.text("rev")?,
path: got.text("path")?,
limit: limit as u32,
}
}
"browse_tree" => Call::BrowseTree {
repo: repo()?,
oid: got.text("oid")?,
},
"read_file" => Call::ReadFile {
repo: repo()?,
oid: got.text("oid")?,
max_bytes: READ_FILE_MAX,
},
"search_code" => Call::SearchCode {
repo: repo()?,
pattern: got.text("pattern")?,
rev: got.text("rev")?,
},
"diff" => Call::Diff {
repo: repo()?,
from: got.text("from")?,
to: got.text("to")?,
paths: got.paths("paths")?,
},
"list_issues" => Call::ListIssues { repo: repo()? },
"get_issue" => Call::GetIssue {
repo: repo()?,
number: got.required_number("number")?,
},
"list_merge_requests" => {
let state = got.text("state")?;
Call::ListMergeRequests {
repo: repo()?,
state: if state.is_empty() {
"all".to_owned()
} else {
state
},
}
}
"get_merge_request" => Call::GetMergeRequest {
repo: repo()?,
number: got.required_number("number")?,
},
"comment_on_issue" => Call::CommentOnIssue {
repo: repo()?,
number: got.required_number("number")?,
body: got.text("body")?,
},
// Unreachable while TOOLS and this match agree; the test
// `every_listed_tool_parses` holds them together.
other => return Err(Refused::Unknown(said_name(other))),
};
Ok(call)
}
struct Got<'a> {
tool: &'static Tool,
args: &'a Map<String, Value>,
}
fn arg(tool: &'static Tool, name: &str) -> &'static Arg {
tool.args
.iter()
.find(|a| a.name == name)
.expect("an argument the tool declares")
}
fn got_str(tool: &'static Tool, args: &Map<String, Value>, name: &str) -> Result<String, Refused> {
let spec = arg(tool, name);
match args.get(name) {
None | Some(Value::Null) if spec.required => {
Err(Refused::Argument(format!("{} needs {name}", tool.name)))
}
None | Some(Value::Null) => Ok(String::new()),
Some(Value::String(s)) => checked(tool, spec, s).map(str::to_owned),
Some(_) => Err(Refused::Argument(format!("{name} must be a string"))),
}
}
impl Got<'_> {
fn text(&self, name: &str) -> Result<String, Refused> {
got_str(self.tool, self.args, name)
}
fn number(&self, name: &str) -> Result<Option<u64>, Refused> {
match self.args.get(name) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| {
Refused::Argument(format!("{name} must be a positive whole number"))
}),
Some(_) => Err(Refused::Argument(format!("{name} must be a number"))),
}
}
fn required_number(&self, name: &str) -> Result<u64, Refused> {
match self.number(name)? {
Some(n) if n > 0 => Ok(n),
Some(_) => Err(Refused::Argument(format!("{name} must be at least 1"))),
None => Err(Refused::Argument(format!(
"{} needs {name}",
self.tool.name
))),
}
}
fn paths(&self, name: &str) -> Result<Vec<String>, Refused> {
let spec = arg(self.tool, name);
match self.args.get(name) {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::Array(items)) if items.len() <= 32 => items
.iter()
.map(|v| match v {
Value::String(s) => checked(
self.tool,
&Arg {
kind: Kind::Path,
..*spec
},
s,
)
.map(str::to_owned),
_ => Err(Refused::Argument(format!(
"{name} must be a list of strings"
))),
})
.collect(),
Some(_) => Err(Refused::Argument(format!(
"{name} must be a list of at most 32 paths"
))),
}
}
}
/// **No address, anywhere.** A value that carries a URL scheme, or that
/// names a host the way an ssh remote does, is refused before its kind is
/// even looked at: nothing this connector does takes an address.
pub fn no_address(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
!(lower.contains("://")
|| lower.starts_with("git@")
|| lower.contains("\\\\")
|| lower.starts_with("//"))
}
fn checked<'v>(tool: &Tool, spec: &Arg, value: &'v str) -> Result<&'v str, Refused> {
let name = spec.name;
if !no_address(value) {
return Err(Refused::Argument(format!("{name} looks like an address, and {} takes none: this connector reaches only this Gunnar's own repositories", tool.name)));
}
if value
.chars()
.any(|c| c.is_control() && c != '\n' && c != '\t')
{
return Err(Refused::Argument(format!(
"{name} carries a control character"
)));
}
let ok = match spec.kind {
Kind::Name => {
!value.is_empty()
&& value.len() <= 100
&& value != "."
&& value != ".."
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
Kind::Oid => {
(value.len() == 40 || value.len() == 64) && value.chars().all(|c| c.is_ascii_hexdigit())
}
Kind::Rev => {
value.len() <= 255
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
&& !value.contains("..")
&& !value.starts_with('-')
}
Kind::Path | Kind::Paths => {
value.len() <= 4096
&& !value.starts_with('/')
&& !value.split('/').any(|s| s == ".." || s == ".")
}
Kind::Text if name == "pattern" => !value.is_empty() && value.len() <= PATTERN_MAX,
Kind::Text => value.len() <= TEXT_MAX,
Kind::OneOf(words) => words.contains(&value),
Kind::Number => false,
};
if ok {
Ok(value)
} else {
Err(Refused::Argument(format!(
"{name} is not {}",
expected(spec.kind)
)))
}
}
fn expected(kind: Kind) -> String {
match kind {
Kind::Name => "a plain name (letters, digits, `.`, `_`, `-`)".to_owned(),
Kind::Oid => "a full hex object id".to_owned(),
Kind::Rev => "a ref name or a hex oid".to_owned(),
Kind::Path | Kind::Paths => "a relative path inside the repository".to_owned(),
Kind::Text => "text of a sensible length".to_owned(),
Kind::Number => "a number".to_owned(),
Kind::OneOf(words) => format!(
"one of {}",
words
.iter()
.map(|w| format!("{w:?}"))
.collect::<Vec<_>>()
.join(", ")
),
}
}
/// The JSON Schema of one argument.
fn schema_of(arg: &Arg) -> Value {
match arg.kind {
Kind::Number => json!({ "type": "integer", "minimum": 1, "description": arg.about }),
Kind::Paths => {
json!({ "type": "array", "items": { "type": "string" }, "maxItems": 32, "description": arg.about })
}
Kind::OneOf(words) => json!({ "type": "string", "enum": words, "description": arg.about }),
Kind::Oid => {
json!({ "type": "string", "pattern": "^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$", "description": arg.about })
}
Kind::Name => {
json!({ "type": "string", "pattern": "^[A-Za-z0-9._-]{1,100}$", "description": arg.about })
}
_ => json!({ "type": "string", "description": arg.about }),
}
}
/// `tools/list`'s answer for `scope`: the read tools, plus the write tools
/// only under the write grant.
pub fn listed(scope: Scope) -> Value {
let tools: Vec<Value> = TOOLS
.iter()
.filter(|t| scope.allows(t))
.map(|t| {
let mut properties = Map::new();
for a in t.args {
properties.insert(a.name.to_owned(), schema_of(a));
}
let required: Vec<&str> = t
.args
.iter()
.filter(|a| a.required)
.map(|a| a.name)
.collect();
json!({
"name": t.name,
"title": t.title,
"description": t.about,
"inputSchema": {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false,
},
"annotations": {
"title": t.title,
"readOnlyHint": !t.write,
"destructiveHint": false,
"idempotentHint": !t.write,
"openWorldHint": false,
},
})
})
.collect();
json!({ "tools": tools })
}
// ── answers ────────────────────────────────────────────────────────────────
/// One repository row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
/// `namespace/name`.
pub path: String,
/// `public`, `internal` or `private`.
pub visibility: String,
/// The default branch.
pub default_branch: String,
/// The description.
pub description: String,
}
/// One ref.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ref {
/// The full ref name.
pub name: String,
/// The oid it points at (peeled when it is an annotated tag).
pub oid: String,
}
/// One commit. The author's NAME only: never an e-mail address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
/// Its oid.
pub oid: String,
/// Its tree's oid.
pub tree: String,
/// Its parents' oids.
pub parents: Vec<String>,
/// The author's name.
pub author: String,
/// The author time, seconds since the epoch.
pub when_unix: i64,
/// The message.
pub message: String,
}
/// One tree entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// The git mode (`100644`, `40000`, …).
pub mode: String,
/// The name.
pub name: String,
/// The oid.
pub oid: String,
}
/// One search match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
/// The file.
pub path: String,
/// The 1-based line number.
pub line: u32,
/// The line.
pub text: String,
}
/// One changed file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
/// `added`, `modified`, `deleted`, `renamed`, `copied`, `type-changed`.
pub kind: String,
/// The path (new side).
pub path: String,
/// The old path, for a rename or copy.
pub old_path: String,
/// The old blob oid.
pub old_oid: String,
/// The new blob oid.
pub new_oid: String,
/// Lines added.
pub insertions: u64,
/// Lines removed.
pub deletions: u64,
/// True for a binary file.
pub binary: bool,
}
/// One issue or merge request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
/// Its number.
pub number: u64,
/// `open`, `closed`, `merged`.
pub state: String,
/// Its title.
pub title: String,
/// Who opened it (a principal name).
pub author: String,
/// Its text.
pub body: String,
/// Further facts (`source: refs/heads/x`, `comments: 3`).
pub facts: Vec<(String, String)>,
}
/// What a backend answers for one [`Call`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
/// `list_repositories`.
Repos(Vec<Repo>),
/// `list_refs`.
Refs(Vec<Ref>),
/// `list_commits`.
Commits(Vec<Commit>),
/// `browse_tree`.
Tree(Vec<Entry>),
/// `read_file`: the bytes, the whole size, and whether it was cut.
File {
oid: String,
size: u64,
data: Vec<u8>,
truncated: bool,
},
/// `search_code`.
Matches(Vec<Match>),
/// `diff`.
Changes(Vec<Change>),
/// `list_issues`, `list_merge_requests`.
Items(Vec<Item>),
/// `get_issue`, `get_merge_request`.
Item(Item),
/// A write tool's sentence.
Done(String),
}
fn kind_of(mode: &str) -> &'static str {
match mode {
"40000" | "040000" => "dir",
"160000" => "submodule",
"120000" => "symlink",
_ => "file",
}
}
/// **The answer as the text the agent reads**, with every e-mail address
/// taken out ([`redact_emails`]).
pub fn render(answer: &Answer) -> String {
let mut out = String::new();
use std::fmt::Write as _;
match answer {
Answer::Repos(rows) if rows.is_empty() => {
out.push_str("No repositories you can see match.\n")
}
Answer::Repos(rows) => {
let _ = writeln!(out, "{} repositories:", rows.len());
for r in rows {
let _ = write!(
out,
"- {} ({}, default branch {})",
r.path,
r.visibility,
if r.default_branch.is_empty() {
"none"
} else {
&r.default_branch
}
);
if !r.description.is_empty() {
let _ = write!(out, ": {}", r.description);
}
out.push('\n');
}
}
Answer::Refs(rows) if rows.is_empty() => out.push_str("No refs.\n"),
Answer::Refs(rows) => {
for r in rows {
let _ = writeln!(out, "{} {}", r.oid, r.name);
}
}
Answer::Commits(rows) if rows.is_empty() => out.push_str("No commits.\n"),
Answer::Commits(rows) => {
for c in rows {
let _ = writeln!(out, "commit {}\ntree {}", c.oid, c.tree);
for p in &c.parents {
let _ = writeln!(out, "parent {p}");
}
let _ = writeln!(out, "author {} at {}\n", c.author, c.when_unix);
for line in c.message.trim_end().lines() {
let _ = writeln!(out, " {line}");
}
out.push('\n');
}
}
Answer::Tree(rows) if rows.is_empty() => out.push_str("An empty tree.\n"),
Answer::Tree(rows) => {
for e in rows {
let _ = writeln!(out, "{} {} {}\t{}", e.mode, kind_of(&e.mode), e.oid, e.name);
}
}
Answer::File {
oid,
size,
data,
truncated,
} => {
let head = &data[..data.len().min(8000)];
if head.contains(&0) {
let _ = writeln!(
out,
"Blob {oid} is a binary file of {size} bytes; it is not shown as text."
);
} else {
out.push_str(&String::from_utf8_lossy(data));
if *truncated {
let _ = write!(
out,
"\n[cut: this is the first {} of {size} bytes]\n",
data.len()
);
}
}
}
Answer::Matches(rows) if rows.is_empty() => out.push_str("No matches.\n"),
Answer::Matches(rows) => {
for m in rows {
let _ = writeln!(out, "{}:{}: {}", m.path, m.line, m.text);
}
}
Answer::Changes(rows) if rows.is_empty() => out.push_str("No files differ.\n"),
Answer::Changes(rows) => {
for c in rows {
let path = if c.old_path.is_empty() || c.old_path == c.path {
c.path.clone()
} else {
format!("{} -> {}", c.old_path, c.path)
};
let lines = if c.binary {
"binary".to_owned()
} else {
format!("+{} -{}", c.insertions, c.deletions)
};
let _ = writeln!(
out,
"{} {path} ({lines}) old {} new {}",
c.kind,
if c.old_oid.is_empty() {
"-"
} else {
&c.old_oid
},
if c.new_oid.is_empty() {
"-"
} else {
&c.new_oid
}
);
}
}
Answer::Items(rows) if rows.is_empty() => out.push_str("None.\n"),
Answer::Items(rows) => {
for i in rows {
let _ = write!(
out,
"#{} [{}] {} (by {})",
i.number, i.state, i.title, i.author
);
for (k, v) in &i.facts {
let _ = write!(out, "; {k}: {v}");
}
out.push('\n');
}
}
Answer::Item(i) => {
let _ = writeln!(
out,
"#{} [{}] {}\nby {}",
i.number, i.state, i.title, i.author
);
for (k, v) in &i.facts {
let _ = writeln!(out, "{k}: {v}");
}
if !i.body.is_empty() {
let _ = writeln!(out, "\n{}", i.body.trim_end());
}
}
Answer::Done(sentence) => {
out.push_str(sentence);
out.push('\n');
}
}
redact_emails(&out)
}
/// What an e-mail address is replaced with.
pub const EMAIL_HIDDEN: &str = "[e-mail hidden]";
/// **Every e-mail address in `text`, replaced** with [`EMAIL_HIDDEN`]. The
/// owner's rule: no e-mail address in a log or an answer. Applied to every
/// answer, file contents included.
pub fn redact_emails(text: &str) -> String {
let bytes = text.as_bytes();
let local = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'%' | b'+' | b'-');
let domain = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-');
let mut out = String::with_capacity(text.len());
let mut copied = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'@' {
i += 1;
continue;
}
let mut start = i;
while start > copied && local(bytes[start - 1]) {
start -= 1;
}
let mut end = i + 1;
while end < bytes.len() && domain(bytes[end]) {
end += 1;
}
// Trailing dots and dashes are punctuation, not the domain.
while end > i + 1 && matches!(bytes[end - 1], b'.' | b'-') {
end -= 1;
}
let dom = &text[i + 1..end];
let tld = dom.rsplit('.').next().unwrap_or("");
let is_email = start < i
&& dom.contains('.')
&& tld.len() >= 2
&& tld.bytes().all(|b| b.is_ascii_alphabetic())
&& !dom.starts_with('.');
if is_email {
out.push_str(&text[copied..start]);
out.push_str(EMAIL_HIDDEN);
copied = end;
i = end;
} else {
i += 1;
}
}
out.push_str(&text[copied..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_listed_tool_parses_and_the_list_is_the_fixed_one() {
let names: Vec<&str> = TOOLS.iter().map(|t| t.name).collect();
assert_eq!(
names,
[
"list_repositories",
"list_refs",
"list_commits",
"browse_tree",
"read_file",
"search_code",
"diff",
"list_issues",
"get_issue",
"list_merge_requests",
"get_merge_request",
"comment_on_issue"
]
);
let oid = "a".repeat(40);
for t in TOOLS {
let mut args = Map::new();
for a in t.args.iter().filter(|a| a.required) {
let v = match a.kind {
Kind::Number => json!(1),
Kind::Oid => json!(oid),
Kind::Rev => json!("main"),
Kind::Paths => json!(["src"]),
Kind::OneOf(w) => json!(w[0]),
_ => json!("x"),
};
args.insert(a.name.to_owned(), v);
}
parse(t.name, Some(&Value::Object(args)), Scope::ReadWrite)
.unwrap_or_else(|e| panic!("{}: {e:?}", t.name));
}
}
#[test]
fn an_unknown_tool_is_refused_by_name() {
let refused = parse(
"fetch_url",
Some(&json!({ "url": "https://example.invalid" })),
Scope::ReadWrite,
)
.unwrap_err();
assert_eq!(refused, Refused::Unknown("fetch_url".to_owned()));
assert!(
refused.words().starts_with("Unknown tool: fetch_url."),
"{}",
refused.words()
);
for name in [
"shell",
"exec",
"proxy",
"http_get",
"run",
"",
"list_repositories ",
] {
assert!(
matches!(
parse(name, None, Scope::ReadWrite),
Err(Refused::Unknown(_))
),
"{name:?}"
);
}
}
#[test]
fn a_write_tool_needs_the_write_grant_and_is_not_even_listed_without_it() {
let args = json!({ "namespace": "kim", "repo": "secret", "number": 1, "body": "hi" });
assert_eq!(
parse("comment_on_issue", Some(&args), Scope::Read).unwrap_err(),
Refused::NeedsWrite("comment_on_issue")
);
assert!(parse("comment_on_issue", Some(&args), Scope::ReadWrite).is_ok());
let read = listed(Scope::Read).to_string();
assert!(!read.contains("comment_on_issue"), "{read}");
assert!(listed(Scope::ReadWrite)
.to_string()
.contains("comment_on_issue"));
}
#[test]
fn no_tool_takes_an_address() {
// The declared arguments: none is named for an address.
for t in TOOLS {
for a in t.args {
for word in [
"url", "uri", "host", "address", "endpoint", "remote", "command", "cmd",
"shell", "server", "port",
] {
assert!(
!a.name.contains(word),
"{}.{} looks like it takes an address",
t.name,
a.name
);
}
}
}
// An undeclared argument is refused, an address in a declared one too.
let extra = parse(
"list_repositories",
Some(&json!({ "url": "https://example.invalid" })),
Scope::Read,
)
.unwrap_err();
assert!(
matches!(extra, Refused::Argument(ref w) if w.contains("no argument named \"url\"")),
"{extra:?}"
);
for value in [
"https://example.invalid/x",
"ssh://git@example.invalid/x",
"git@example.invalid:x",
"//example.invalid/x",
"file:///etc/passwd",
] {
let r = parse(
"list_repositories",
Some(&json!({ "query": value })),
Scope::Read,
)
.unwrap_err();
assert!(
matches!(r, Refused::Argument(ref w) if w.contains("address")),
"{value}: {r:?}"
);
let r = parse(
"search_code",
Some(&json!({ "namespace": "a", "repo": "b", "pattern": value })),
Scope::Read,
)
.unwrap_err();
assert!(matches!(r, Refused::Argument(_)), "{value}: {r:?}");
}
}
#[test]
fn arguments_are_checked_by_kind() {
let bad = |args: Value| parse("browse_tree", Some(&args), Scope::Read).unwrap_err();
assert!(matches!(
bad(json!({ "namespace": "a", "repo": "b", "oid": "main" })),
Refused::Argument(_)
));
assert!(matches!(
bad(json!({ "namespace": "../a", "repo": "b", "oid": "a".repeat(40) })),
Refused::Argument(_)
));
assert!(
matches!(bad(json!({ "namespace": "a", "repo": "b" })), Refused::Argument(ref w) if w.contains("needs oid"))
);
let path = parse(
"list_commits",
Some(&json!({ "namespace": "a", "repo": "b", "path": "../etc" })),
Scope::Read,
)
.unwrap_err();
assert!(matches!(path, Refused::Argument(_)));
let limit = parse(
"list_commits",
Some(&json!({ "namespace": "a", "repo": "b", "limit": 500 })),
Scope::Read,
)
.unwrap_err();
assert!(matches!(limit, Refused::Argument(_)));
assert_eq!(
parse(
"list_commits",
Some(&json!({ "namespace": "a", "repo": "b" })),
Scope::Read
)
.unwrap(),
Call::ListCommits {
repo: RepoName {
namespace: "a".into(),
name: "b".into()
},
rev: String::new(),
path: String::new(),
limit: 20
}
);
}
#[test]
fn every_schema_forbids_extra_properties() {
let listed = listed(Scope::ReadWrite);
for t in listed["tools"].as_array().unwrap() {
assert_eq!(
t["inputSchema"]["additionalProperties"],
json!(false),
"{t}"
);
assert_eq!(t["inputSchema"]["type"], "object");
}
}
#[test]
fn e_mail_addresses_never_reach_an_answer() {
assert_eq!(
redact_emails("Signed-off-by: Kim <kim@mock.invalid>"),
format!("Signed-off-by: Kim <{EMAIL_HIDDEN}>")
);
assert_eq!(
redact_emails("a.b+c@sub.mock.invalid."),
format!("{EMAIL_HIDDEN}.")
);
assert_eq!(
redact_emails("git@host:repo and @mention and x@y"),
"git@host:repo and @mention and x@y"
);
assert_eq!(redact_emails("no address here"), "no address here");
let answer = Answer::Commits(vec![Commit {
oid: "1".repeat(40),
tree: "2".repeat(40),
parents: vec![],
author: "Kim".into(),
when_unix: 1,
message: "fix\n\nCo-authored-by: Eve <eve@mock.invalid>\n".into(),
}]);
let text = render(&answer);
assert!(!text.contains("@mock.invalid"), "{text}");
assert!(text.contains(EMAIL_HIDDEN));
}
#[test]
fn a_binary_file_is_described_and_a_cut_one_says_so() {
let bin = render(&Answer::File {
oid: "o".into(),
size: 3,
data: vec![0, 1, 2],
truncated: false,
});
assert!(bin.contains("binary file of 3 bytes"), "{bin}");
let cut = render(&Answer::File {
oid: "o".into(),
size: 10,
data: b"hello".to_vec(),
truncated: true,
});
assert!(
cut.starts_with("hello") && cut.contains("first 5 of 10 bytes"),
"{cut}"
);
}
}