Add typed tool arguments and the form checks for paths, hosts and URLs
Implemented brokerd::args: a pure module (no I/O, no clock) that parses tool arguments into a typed ToolArgs and checks the form of paths, hosts and URLs. Four private deny_unknown_fields structs drive parse and canonical_json; path, cwd and url are validated as written and never normalised. All 13 args tests pass and make gate prints gate: ok. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
//! Tool arguments and the form checks for paths, hosts and URLs.
|
||||
//!
|
||||
//! `parse` turns the model's argument string into a typed value; the free functions say whether a
|
||||
//! path, a host or a URL is well formed. The module is pure: no I/O, no clock. Everything it reads
|
||||
//! was written by the model, so it is treated as hostile.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Maximum length, in bytes, of a path or of a URL.
|
||||
pub const MAX_PATH: usize = 4096;
|
||||
/// Maximum length, in bytes, of a URL.
|
||||
pub const MAX_URL: usize = 2048;
|
||||
|
||||
/// The four tools the broker can dispatch to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolName {
|
||||
ReadFile,
|
||||
WriteFile,
|
||||
Shell,
|
||||
HttpFetch,
|
||||
}
|
||||
|
||||
impl ToolName {
|
||||
/// The four tools, in the order listed in the interface.
|
||||
pub const ALL: [ToolName; 4] = [
|
||||
ToolName::ReadFile,
|
||||
ToolName::WriteFile,
|
||||
ToolName::Shell,
|
||||
ToolName::HttpFetch,
|
||||
];
|
||||
|
||||
/// Parse a tool name as written by the model.
|
||||
pub fn parse(name: &str) -> Option<ToolName> {
|
||||
match name {
|
||||
"read_file" => Some(ToolName::ReadFile),
|
||||
"write_file" => Some(ToolName::WriteFile),
|
||||
"shell" => Some(ToolName::Shell),
|
||||
"http_fetch" => Some(ToolName::HttpFetch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The name as written by the model.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ToolName::ReadFile => "read_file",
|
||||
ToolName::WriteFile => "write_file",
|
||||
ToolName::Shell => "shell",
|
||||
ToolName::HttpFetch => "http_fetch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed set of tool arguments, one variant per tool.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ToolArgs {
|
||||
ReadFile {
|
||||
path: String,
|
||||
},
|
||||
WriteFile {
|
||||
path: String,
|
||||
content: String,
|
||||
},
|
||||
Shell {
|
||||
command: String,
|
||||
cwd: Option<String>,
|
||||
},
|
||||
HttpFetch {
|
||||
url: String,
|
||||
host: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolArgs {
|
||||
/// The tool these arguments belong to.
|
||||
pub fn tool(&self) -> ToolName {
|
||||
match self {
|
||||
ToolArgs::ReadFile { .. } => ToolName::ReadFile,
|
||||
ToolArgs::WriteFile { .. } => ToolName::WriteFile,
|
||||
ToolArgs::Shell { .. } => ToolName::Shell,
|
||||
ToolArgs::HttpFetch { .. } => ToolName::HttpFetch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The arguments written out again, so two spellings of one value look the same.
|
||||
///
|
||||
/// Fields come out in the table's order, an absent `cwd` is left out, and `host` is never
|
||||
/// written because it is not an argument. A serialization failure yields `"{}"`.
|
||||
pub fn canonical_json(&self) -> String {
|
||||
match self {
|
||||
ToolArgs::ReadFile { path } => {
|
||||
let value = ReadFileArgs { path: path.clone() };
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
ToolArgs::WriteFile { path, content } => {
|
||||
let value = WriteFileArgs {
|
||||
path: path.clone(),
|
||||
content: content.clone(),
|
||||
};
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
ToolArgs::Shell { command, cwd } => {
|
||||
let value = ShellArgs {
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
};
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
ToolArgs::HttpFetch { url, .. } => {
|
||||
let value = HttpFetchArgs { url: url.clone() };
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `parse` refused a set of arguments.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ArgsError {
|
||||
/// The arguments were not the right shape for the tool.
|
||||
Shape(String),
|
||||
/// A path was not well formed.
|
||||
Path(String),
|
||||
/// A URL gave no valid host.
|
||||
Url(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ArgsError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ArgsError::Shape(text) => write!(f, "invalid argument shape: {text}"),
|
||||
ArgsError::Path(text) => write!(f, "invalid path: {text}"),
|
||||
ArgsError::Url(text) => write!(f, "invalid url: {text}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ArgsError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The argument shape for one tool, decoded then re-serialised.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ReadFileArgs {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WriteFileArgs {
|
||||
path: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ShellArgs {
|
||||
command: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct HttpFetchArgs {
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Decode `arguments` for `tool` into a typed value.
|
||||
pub fn parse(tool: ToolName, arguments: &str) -> Result<ToolArgs, ArgsError> {
|
||||
match tool {
|
||||
ToolName::ReadFile => {
|
||||
let value: ReadFileArgs = match serde_json::from_str(arguments) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(ArgsError::Shape(error.to_string())),
|
||||
};
|
||||
if !valid_path(&value.path) {
|
||||
return Err(ArgsError::Path(value.path));
|
||||
}
|
||||
Ok(ToolArgs::ReadFile { path: value.path })
|
||||
}
|
||||
ToolName::WriteFile => {
|
||||
let value: WriteFileArgs = match serde_json::from_str(arguments) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(ArgsError::Shape(error.to_string())),
|
||||
};
|
||||
if !valid_path(&value.path) {
|
||||
return Err(ArgsError::Path(value.path));
|
||||
}
|
||||
Ok(ToolArgs::WriteFile {
|
||||
path: value.path,
|
||||
content: value.content,
|
||||
})
|
||||
}
|
||||
ToolName::Shell => {
|
||||
let value: ShellArgs = match serde_json::from_str(arguments) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(ArgsError::Shape(error.to_string())),
|
||||
};
|
||||
if let Some(cwd) = &value.cwd
|
||||
&& !valid_path(cwd)
|
||||
{
|
||||
return Err(ArgsError::Path(cwd.clone()));
|
||||
}
|
||||
Ok(ToolArgs::Shell {
|
||||
command: value.command,
|
||||
cwd: value.cwd,
|
||||
})
|
||||
}
|
||||
ToolName::HttpFetch => {
|
||||
let value: HttpFetchArgs = match serde_json::from_str(arguments) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(ArgsError::Shape(error.to_string())),
|
||||
};
|
||||
let host = match url_host(&value.url) {
|
||||
Some(host) => host.to_string(),
|
||||
None => return Err(ArgsError::Url(value.url)),
|
||||
};
|
||||
Ok(ToolArgs::HttpFetch {
|
||||
url: value.url,
|
||||
host,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A path is well formed if it is at most `MAX_PATH` bytes, has no NUL, starts with `/`, and every
|
||||
/// component is a whole, non-dot name. The root `/` alone is valid.
|
||||
pub fn valid_path(path: &str) -> bool {
|
||||
if !(1..=MAX_PATH).contains(&path.len()) || path.contains('\0') {
|
||||
return false;
|
||||
}
|
||||
let rest = match path.strip_prefix('/') {
|
||||
Some(rest) => rest,
|
||||
None => return false,
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return true;
|
||||
}
|
||||
for component in rest.split('/') {
|
||||
if component.is_empty() || component == "." || component == ".." {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Is `path` inside `grant_path`, by whole components rather than by bytes?
|
||||
pub fn inside(grant_path: &str, path: &str) -> bool {
|
||||
match path.strip_prefix(grant_path) {
|
||||
Some(rest) => rest.is_empty() || rest.starts_with('/') || grant_path == "/",
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A host label is 1 to 63 bytes of `a-z`, `0-9` or `-`, and neither starts nor ends with `-`.
|
||||
fn valid_label(label: &str) -> bool {
|
||||
if !(1..=63).contains(&label.len()) {
|
||||
return false;
|
||||
}
|
||||
let bytes = label.as_bytes();
|
||||
if bytes.iter().next() == Some(&b'-') || bytes.iter().last() == Some(&b'-') {
|
||||
return false;
|
||||
}
|
||||
bytes
|
||||
.iter()
|
||||
.all(|&byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-'))
|
||||
}
|
||||
|
||||
/// A host is 1 to 253 bytes of dot-separated labels, each a whole non-dot name, and the last label
|
||||
/// starts with a letter. That last rule keeps out every spelling of an IPv4 address.
|
||||
pub fn valid_host(host: &str) -> bool {
|
||||
if !(1..=253).contains(&host.len()) {
|
||||
return false;
|
||||
}
|
||||
let labels: Vec<&str> = host.split('.').collect();
|
||||
if labels.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
for label in &labels {
|
||||
if !valid_label(label) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
match labels.iter().last() {
|
||||
Some(last) => matches!(last.bytes().next(), Some(byte) if byte.is_ascii_lowercase()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A host, or `*.` followed by a host. Nothing else.
|
||||
pub fn valid_host_pattern(pattern: &str) -> bool {
|
||||
match pattern.strip_prefix("*.") {
|
||||
Some(base) => valid_host(base),
|
||||
None => valid_host(pattern),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `pattern` match `host`? Without `*.` the strings must be equal; with `*.base` the host must
|
||||
/// end in `.base` with something before the dot.
|
||||
pub fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
let base = match pattern.strip_prefix("*.") {
|
||||
Some(base) => base,
|
||||
None => return pattern == host,
|
||||
};
|
||||
match host.strip_suffix(base) {
|
||||
Some(before) => match before.strip_suffix('.') {
|
||||
Some(prefix) => !prefix.is_empty(),
|
||||
None => false,
|
||||
},
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A character that may appear in a host name.
|
||||
fn host_char(c: char) -> bool {
|
||||
matches!(c, 'a'..='z' | '0'..='9' | '.' | '-')
|
||||
}
|
||||
|
||||
/// The host of a URL, if the URL is well formed. See the task's `url_host` rules for the order.
|
||||
pub fn url_host(url: &str) -> Option<&str> {
|
||||
if url.len() > MAX_URL {
|
||||
return None;
|
||||
}
|
||||
let without_scheme = url.strip_prefix("https://")?;
|
||||
// Uppercase is not allowed anywhere in a valid URL, so the host is read as written.
|
||||
let end = match without_scheme.find(|c: char| !host_char(c)) {
|
||||
Some(end) => end,
|
||||
None => without_scheme.len(),
|
||||
};
|
||||
let (host, rest) = without_scheme.split_at_checked(end)?;
|
||||
if !valid_host(host) {
|
||||
return None;
|
||||
}
|
||||
// A trailing `:443` is allowed; any other port, or anything else, must be the end or a path.
|
||||
let after = match rest.strip_prefix(":443") {
|
||||
Some(after) => after,
|
||||
None => rest,
|
||||
};
|
||||
let remainder = if after.is_empty() {
|
||||
after
|
||||
} else {
|
||||
after.strip_prefix('/')?
|
||||
};
|
||||
if remainder.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
|
||||
Some(host)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The broker: the only role that holds authority.
|
||||
|
||||
pub mod args;
|
||||
pub mod config;
|
||||
pub mod policy;
|
||||
pub mod runner;
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Tests for tool arguments: their shape, and the form of paths, hosts and URLs. Do not edit.
|
||||
//!
|
||||
//! Every row of the "Paths" and "Hosts" tables in the M3a spec is a case here.
|
||||
|
||||
use brokerd::args::{
|
||||
ArgsError, MAX_PATH, MAX_URL, ToolArgs, ToolName, host_matches, inside, parse, url_host,
|
||||
valid_host, valid_host_pattern, valid_path,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn the_four_tool_names() {
|
||||
let names = ["read_file", "write_file", "shell", "http_fetch"];
|
||||
for (tool, name) in ToolName::ALL.into_iter().zip(names) {
|
||||
assert_eq!(tool.as_str(), name);
|
||||
assert_eq!(ToolName::parse(name), Some(tool));
|
||||
}
|
||||
for other in [
|
||||
"",
|
||||
"echo",
|
||||
"clock",
|
||||
"call_tool",
|
||||
"Read_File",
|
||||
"read_file ",
|
||||
"readfile",
|
||||
] {
|
||||
assert_eq!(ToolName::parse(other), None, "{other:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_paths() {
|
||||
let longest = format!("/{}", "a".repeat(MAX_PATH - 1));
|
||||
assert_eq!(longest.len(), MAX_PATH);
|
||||
for path in [
|
||||
"/",
|
||||
"/etc",
|
||||
"/home/kyle/notes/a.md",
|
||||
"/home/kyle/notes",
|
||||
"/with space/and\ttab",
|
||||
"/dots.in.names/..hidden/...",
|
||||
"/unicode/\u{e9}t\u{e9}",
|
||||
longest.as_str(),
|
||||
] {
|
||||
assert!(valid_path(path), "{path:?} should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_paths() {
|
||||
let too_long = format!("/{}", "a".repeat(MAX_PATH));
|
||||
assert_eq!(too_long.len(), MAX_PATH + 1);
|
||||
for path in [
|
||||
"",
|
||||
"notes/a.md",
|
||||
"./notes",
|
||||
"~/notes",
|
||||
"/home/kyle/notes/../.ssh/id",
|
||||
"/home/kyle//notes/./a.md",
|
||||
"/home//kyle",
|
||||
"/home/./kyle",
|
||||
"/home/kyle/",
|
||||
"/home/kyle/..",
|
||||
"/..",
|
||||
"/.",
|
||||
"//",
|
||||
"/nul\0byte",
|
||||
too_long.as_str(),
|
||||
] {
|
||||
assert!(!valid_path(path), "{path:?} should be invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/// The table in the spec, row by row, for the rows about form and containment.
|
||||
#[test]
|
||||
fn inside_is_by_whole_components() {
|
||||
let grant = "/home/kyle/notes";
|
||||
assert!(inside(grant, "/home/kyle/notes/a.md"));
|
||||
assert!(inside(grant, "/home/kyle/notes"));
|
||||
assert!(inside(grant, "/home/kyle/notes/deep/er/b.md"));
|
||||
assert!(!inside(grant, "/home/kyle/notes2/a.md"));
|
||||
assert!(!inside(grant, "/home/kyle/note"));
|
||||
assert!(!inside(grant, "/home/kyle"));
|
||||
assert!(!inside(grant, "/"));
|
||||
assert!(!inside(grant, "/other/home/kyle/notes/a.md"));
|
||||
// A grant of the root is refused when grants are loaded, but the function is still right.
|
||||
assert!(inside("/", "/etc/passwd"));
|
||||
assert!(inside("/", "/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_hosts_and_patterns() {
|
||||
let label63 = "a".repeat(63);
|
||||
let long = format!("{label63}.{label63}.{label63}.{}.com", "b".repeat(57));
|
||||
assert_eq!(long.len(), 253);
|
||||
for host in [
|
||||
"example.com",
|
||||
"www.example.com",
|
||||
"a.b.example.com",
|
||||
"xn--bcher-kva.example",
|
||||
"1password.com",
|
||||
"3.example.org",
|
||||
"a-b.c-d.io",
|
||||
long.as_str(),
|
||||
] {
|
||||
assert!(valid_host(host), "{host:?} should be a valid host");
|
||||
assert!(
|
||||
valid_host_pattern(host),
|
||||
"{host:?} should be a valid pattern"
|
||||
);
|
||||
let wild = format!("*.{host}");
|
||||
assert!(!valid_host(&wild), "{wild:?} is a pattern, not a host");
|
||||
}
|
||||
assert!(valid_host_pattern("*.example.com"));
|
||||
assert!(valid_host_pattern("*.a.b.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_hosts_and_patterns() {
|
||||
let label64 = format!("{}.com", "a".repeat(64));
|
||||
let too_long = format!("{}.com", ["a".repeat(63).as_str(); 4].join("."));
|
||||
assert!(too_long.len() > 253);
|
||||
for host in [
|
||||
"",
|
||||
"localhost",
|
||||
"com",
|
||||
"Example.com",
|
||||
"example.COM",
|
||||
"example.com.",
|
||||
".example.com",
|
||||
"example..com",
|
||||
"-example.com",
|
||||
"example-.com",
|
||||
"exa_mple.com",
|
||||
"example.com:443",
|
||||
"example.com/path",
|
||||
"user@example.com",
|
||||
"exa mple.com",
|
||||
"[::1]",
|
||||
"::1",
|
||||
// Every spelling of an IPv4 address: the last label does not start with a letter.
|
||||
"127.0.0.1",
|
||||
"127.1",
|
||||
"10.0.0.0x1",
|
||||
"1.2.3.4",
|
||||
"example.123",
|
||||
"b\u{fc}cher.example",
|
||||
label64.as_str(),
|
||||
too_long.as_str(),
|
||||
] {
|
||||
assert!(!valid_host(host), "{host:?} should not be a valid host");
|
||||
assert!(
|
||||
!valid_host_pattern(host),
|
||||
"{host:?} should not be a valid pattern"
|
||||
);
|
||||
}
|
||||
for pattern in [
|
||||
"*",
|
||||
"*.",
|
||||
"*.com",
|
||||
"*example.com",
|
||||
"www.*.com",
|
||||
"*.*.example.com",
|
||||
"**.example.com",
|
||||
"*.Example.com",
|
||||
"*.127.0.0.1",
|
||||
] {
|
||||
assert!(!valid_host_pattern(pattern), "{pattern:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The host table in the spec, row by row.
|
||||
#[test]
|
||||
fn host_matching() {
|
||||
assert!(host_matches("example.com", "example.com"));
|
||||
assert!(!host_matches("example.com", "www.example.com"));
|
||||
assert!(host_matches("*.example.com", "www.example.com"));
|
||||
assert!(host_matches("*.example.com", "a.b.example.com"));
|
||||
assert!(!host_matches("*.example.com", "example.com"));
|
||||
// A suffix is not enough: the match is by whole labels.
|
||||
assert!(!host_matches("*.example.com", "badexample.com"));
|
||||
assert!(!host_matches("*.example.com", "www.example.com.evil.org"));
|
||||
assert!(!host_matches("example.com", "example.com.evil.org"));
|
||||
assert!(!host_matches("*.example.com", ".example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_urls_and_their_hosts() {
|
||||
let base = "https://example.com/";
|
||||
let longest = format!("{base}{}", "a".repeat(MAX_URL - base.len()));
|
||||
assert_eq!(longest.len(), MAX_URL);
|
||||
for (url, host) in [
|
||||
("https://example.com", "example.com"),
|
||||
("https://example.com/", "example.com"),
|
||||
("https://example.com:443", "example.com"),
|
||||
("https://example.com:443/", "example.com"),
|
||||
("https://www.example.com/a/b.html", "www.example.com"),
|
||||
("https://example.com/search?q=a+b&x=%20#frag", "example.com"),
|
||||
("https://example.com/@user", "example.com"),
|
||||
("https://example.com/a:8080/b", "example.com"),
|
||||
("https://example.com/https://other.org/", "example.com"),
|
||||
("https://example.com/back\\slash", "example.com"),
|
||||
(longest.as_str(), "example.com"),
|
||||
] {
|
||||
assert_eq!(url_host(url), Some(host), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_urls() {
|
||||
let base = "https://example.com/";
|
||||
let too_long = format!("{base}{}", "a".repeat(MAX_URL - base.len() + 1));
|
||||
assert_eq!(too_long.len(), MAX_URL + 1);
|
||||
for url in [
|
||||
"",
|
||||
"example.com",
|
||||
"http://example.com/",
|
||||
"HTTPS://example.com/",
|
||||
"https:/example.com/",
|
||||
"https://",
|
||||
"https:///path",
|
||||
"ftp://example.com/",
|
||||
"file:///etc/passwd",
|
||||
// userinfo
|
||||
"https://user@example.com/",
|
||||
"https://user:pw@example.com/",
|
||||
"https://example.com@evil.org/",
|
||||
// ports
|
||||
"https://example.com:8443/",
|
||||
"https://example.com:80/",
|
||||
"https://example.com:/",
|
||||
"https://example.com:443x/",
|
||||
"https://example.com:4433/",
|
||||
"https://example.com:443:443/",
|
||||
// what follows the host must be the end, `:443` or `/`
|
||||
"https://example.com?q=1",
|
||||
"https://example.com#frag",
|
||||
"https://example.com\\@evil.org/",
|
||||
// hosts that are not host names
|
||||
"https://localhost/",
|
||||
"https://127.0.0.1/",
|
||||
"https://127.1/",
|
||||
"https://[::1]/",
|
||||
"https://Example.com/",
|
||||
"https://example.com./",
|
||||
"https://b\u{fc}cher.example/",
|
||||
// the rest must be printable ASCII with no space
|
||||
"https://example.com/a b",
|
||||
"https://example.com/a\tb",
|
||||
"https://example.com/a\nb",
|
||||
"https://example.com/caf\u{e9}",
|
||||
"https://example.com/\u{7f}",
|
||||
" https://example.com/",
|
||||
"https://example.com/ ",
|
||||
too_long.as_str(),
|
||||
] {
|
||||
assert_eq!(url_host(url), None, "{url:?} should be invalid");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_tool_parses_its_own_arguments() {
|
||||
assert_eq!(
|
||||
parse(ToolName::ReadFile, r#"{"path":"/home/kyle/notes/a.md"}"#),
|
||||
Ok(ToolArgs::ReadFile {
|
||||
path: "/home/kyle/notes/a.md".to_string()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse(
|
||||
ToolName::WriteFile,
|
||||
r#"{"path":"/home/kyle/notes/a.md","content":"line\n"}"#
|
||||
),
|
||||
Ok(ToolArgs::WriteFile {
|
||||
path: "/home/kyle/notes/a.md".to_string(),
|
||||
content: "line\n".to_string()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse(ToolName::Shell, r#"{"command":"ls -l"}"#),
|
||||
Ok(ToolArgs::Shell {
|
||||
command: "ls -l".to_string(),
|
||||
cwd: None
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse(ToolName::Shell, r#"{"command":"ls","cwd":"/home/kyle"}"#),
|
||||
Ok(ToolArgs::Shell {
|
||||
command: "ls".to_string(),
|
||||
cwd: Some("/home/kyle".to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse(
|
||||
ToolName::HttpFetch,
|
||||
r#"{"url":"https://www.example.com/a"}"#
|
||||
),
|
||||
Ok(ToolArgs::HttpFetch {
|
||||
url: "https://www.example.com/a".to_string(),
|
||||
host: "www.example.com".to_string()
|
||||
})
|
||||
);
|
||||
// Field order and white space in the request do not matter.
|
||||
assert_eq!(
|
||||
parse(
|
||||
ToolName::WriteFile,
|
||||
" { \"content\" : \"x\" , \"path\" : \"/a/b\" } "
|
||||
),
|
||||
Ok(ToolArgs::WriteFile {
|
||||
path: "/a/b".to_string(),
|
||||
content: "x".to_string()
|
||||
})
|
||||
);
|
||||
// `command` and `content` are not inspected.
|
||||
assert!(parse(ToolName::Shell, r#"{"command":""}"#).is_ok());
|
||||
assert!(parse(ToolName::Shell, r#"{"command":"rm -rf / ; ../../x"}"#).is_ok());
|
||||
assert!(parse(ToolName::WriteFile, r#"{"path":"/a/b","content":""}"#).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_of_the_wrong_shape_are_refused() {
|
||||
let cases: [(ToolName, &str); 17] = [
|
||||
(ToolName::ReadFile, ""),
|
||||
(ToolName::ReadFile, "null"),
|
||||
(ToolName::ReadFile, "[]"),
|
||||
(ToolName::ReadFile, r#""/etc/hosts""#),
|
||||
(ToolName::ReadFile, "{}"),
|
||||
(ToolName::ReadFile, r#"{"path":"/a","mode":"r"}"#),
|
||||
(ToolName::ReadFile, r#"{"path":"/a","path":"/b"}"#),
|
||||
(ToolName::ReadFile, r#"{"path":7}"#),
|
||||
(ToolName::ReadFile, r#"{"path":null}"#),
|
||||
(ToolName::ReadFile, r#"{"path":"/a"} trailing"#),
|
||||
(ToolName::WriteFile, r#"{"path":"/a/b"}"#),
|
||||
(ToolName::WriteFile, r#"{"content":"x"}"#),
|
||||
(
|
||||
ToolName::WriteFile,
|
||||
r#"{"path":"/a/b","content":"x","append":true}"#,
|
||||
),
|
||||
(ToolName::Shell, r#"{"cwd":"/a"}"#),
|
||||
(ToolName::Shell, r#"{"command":"ls","cwd":"/a","env":{}}"#),
|
||||
(ToolName::Shell, r#"{"command":["ls"]}"#),
|
||||
(
|
||||
ToolName::HttpFetch,
|
||||
r#"{"url":"https://example.com/","method":"POST"}"#,
|
||||
),
|
||||
];
|
||||
for (tool, text) in cases {
|
||||
match parse(tool, text) {
|
||||
Err(ArgsError::Shape(_)) => {}
|
||||
other => panic!("{tool:?} {text:?}: expected a shape error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// One tool's arguments do not fit another tool.
|
||||
assert!(parse(ToolName::Shell, r#"{"path":"/a"}"#).is_err());
|
||||
assert!(parse(ToolName::ReadFile, r#"{"url":"https://example.com/"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_path_or_url_is_refused_in_every_tool_that_takes_one() {
|
||||
for bad in ["notes/a.md", "/home/kyle/notes/../.ssh/id", "/a//b", "/a/"] {
|
||||
let quoted = serde_json::to_string(bad).unwrap();
|
||||
let read = format!(r#"{{"path":{quoted}}}"#);
|
||||
let write = format!(r#"{{"path":{quoted},"content":"x"}}"#);
|
||||
let shell = format!(r#"{{"command":"ls","cwd":{quoted}}}"#);
|
||||
assert_eq!(
|
||||
parse(ToolName::ReadFile, &read),
|
||||
Err(ArgsError::Path(bad.to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(ToolName::WriteFile, &write),
|
||||
Err(ArgsError::Path(bad.to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(ToolName::Shell, &shell),
|
||||
Err(ArgsError::Path(bad.to_string()))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
parse(ToolName::HttpFetch, r#"{"url":"http://example.com/"}"#),
|
||||
Err(ArgsError::Url("http://example.com/".to_string()))
|
||||
);
|
||||
// A NUL can only arrive as a JSON escape; it is refused once decoded.
|
||||
let nul = format!("{{\"path\":\"/a{}u0000b\"}}", '\\');
|
||||
assert!(matches!(
|
||||
parse(ToolName::ReadFile, &nul),
|
||||
Err(ArgsError::Path(_))
|
||||
));
|
||||
// `cwd: null` is the same as no `cwd`.
|
||||
assert_eq!(
|
||||
parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#),
|
||||
Ok(ToolArgs::Shell {
|
||||
command: "ls".to_string(),
|
||||
cwd: None
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// What the owner is shown is the parsed value written out again, so two spellings of one path
|
||||
/// look the same. The escape is built from pieces so that no tool rewrites it on the way here.
|
||||
#[test]
|
||||
fn canonical_json_shows_what_was_parsed() {
|
||||
let escaped_slash = format!("{}u002f", '\\');
|
||||
let sneaky = format!("{{ \"path\" : \"{escaped_slash}etc{escaped_slash}hosts\" }}");
|
||||
assert!(sneaky.contains("u002fetc"));
|
||||
let args = parse(ToolName::ReadFile, &sneaky).unwrap();
|
||||
assert_eq!(
|
||||
args,
|
||||
ToolArgs::ReadFile {
|
||||
path: "/etc/hosts".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(args.canonical_json(), r#"{"path":"/etc/hosts"}"#);
|
||||
|
||||
// Fields come out in the spec's order whatever order they came in.
|
||||
let write = parse(ToolName::WriteFile, r#"{"content":"x\ny","path":"/a/b"}"#).unwrap();
|
||||
assert_eq!(
|
||||
write.canonical_json(),
|
||||
r#"{"path":"/a/b","content":"x\ny"}"#
|
||||
);
|
||||
let shell = parse(ToolName::Shell, r#"{"cwd":"/a","command":"ls"}"#).unwrap();
|
||||
assert_eq!(shell.canonical_json(), r#"{"command":"ls","cwd":"/a"}"#);
|
||||
// An absent cwd is left out, and the host is never written: it is not an argument.
|
||||
let bare = parse(ToolName::Shell, r#"{"command":"ls","cwd":null}"#).unwrap();
|
||||
assert_eq!(bare.canonical_json(), r#"{"command":"ls"}"#);
|
||||
let fetch = parse(ToolName::HttpFetch, r#"{"url":"https://example.com/a"}"#).unwrap();
|
||||
assert_eq!(fetch.canonical_json(), r#"{"url":"https://example.com/a"}"#);
|
||||
assert_eq!(fetch.tool(), ToolName::HttpFetch);
|
||||
assert_eq!(write.tool(), ToolName::WriteFile);
|
||||
}
|
||||
Reference in New Issue
Block a user