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:
2026-09-19 02:37:31 -07:00
parent 5502de1c90
commit 9150effc8e
4 changed files with 785 additions and 1 deletions
+354
View File
@@ -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
View File
@@ -1,5 +1,6 @@
//! The broker: the only role that holds authority.
pub mod args;
pub mod config;
pub mod policy;
pub mod runner;