Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.5 KiB
M3a task 05: tool arguments, paths, hosts and URLs
Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop)
Commit subject: Add typed tool arguments and the form checks for paths, hosts and URLs
Goal
brokerd::args turns the model's argument string into a typed value, and says whether a path, a
host or a URL is well formed. It knows nothing about grants. Paths are compared as written and
never normalised, so every form that would need normalising is refused here.
This module is pure: no I/O, no clock. Everything it reads was written by the model, so treat it as
hostile: no unwrap, no indexing, no slicing with [a..b].
Files
- Copy:
crates/brokerd/tests/args.rs - Create:
crates/brokerd/src/args.rs - Modify:
crates/brokerd/src/lib.rs(addpub mod args;),docs/implementer-log.md
Interfaces
pub const MAX_PATH: usize = 4096;
pub const MAX_URL: usize = 2048;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolName { ReadFile, WriteFile, Shell, HttpFetch }
impl ToolName {
pub const ALL: [ToolName; 4]; // in the order above
pub fn parse(name: &str) -> Option<ToolName>; // "read_file" | "write_file" | "shell" | "http_fetch"
pub fn as_str(self) -> &'static str;
}
#[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 }, // host is taken from url, not an argument
}
impl ToolArgs {
pub fn tool(&self) -> ToolName;
pub fn canonical_json(&self) -> String;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArgsError { Shape(String), Path(String), Url(String) } // Display + Error, by hand
pub fn parse(tool: ToolName, arguments: &str) -> Result<ToolArgs, ArgsError>;
pub fn valid_path(path: &str) -> bool;
pub fn inside(grant_path: &str, path: &str) -> bool;
pub fn valid_host(host: &str) -> bool;
pub fn valid_host_pattern(pattern: &str) -> bool;
pub fn host_matches(pattern: &str, host: &str) -> bool;
pub fn url_host(url: &str) -> Option<&str>; // Some(host) if and only if the URL is valid
Rules
parse. Decode with four private structs, one per tool, each
#[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)], fields in this order:
| Tool | Struct fields |
|---|---|
read_file |
path: String |
write_file |
path: String, content: String |
shell |
command: String, cwd: Option<String> with #[serde(default, skip_serializing_if = "Option::is_none")] |
http_fetch |
url: String |
serde_json::from_strfails (not JSON, not an object, unknown field, missing field, wrong type, a key given twice, text after the object):ArgsError::Shape(the error's text). serde rejects a repeated key in a derived struct by itself.path, andcwdwhen present, must passvalid_path, elseArgsError::Path(that path). This applies in all three tools that take a path:read_file,write_fileandshell.urlmust giveSome(host)fromurl_host, elseArgsError::Url(that url). Keep the host.commandandcontentare never inspected. Empty is fine.
canonical_json. Serialise the same private struct again from the parsed value, so
{ "path" : "\u002fetc" } and {"path":"/etc"} both give {"path":"/etc"}. Fields come out in
the table's order. An absent cwd is left out. host is never written. If to_string fails,
return "{}"; do not unwrap.
valid_path. All of: at most MAX_PATH bytes; no NUL ('\0'); starts with /; and the rest,
split on /, has no empty part, no . and no ... The root / alone is valid (the rest is
empty; check that before splitting). So /a/ and // are invalid, /a/... and /..a are valid.
inside(grant_path, path). By whole components, never by bytes:
path.strip_prefix(grant_path) is Some(rest) and rest is empty, or starts with /, or
grant_path is /. So /home/kyle/notes2 is not inside /home/kyle/notes.
valid_host. All of: 1 to 253 bytes; split on . gives at least two labels; each label is 1
to 63 bytes of a-z, 0-9, -, and neither starts nor ends with -; the last label starts
with a letter a-z. That last rule is what keeps out every spelling of an IPv4 address
(127.0.0.1, 127.1, 10.0.0.0x1): their labels are otherwise legal.
valid_host_pattern. A valid host, or *. followed by a valid host. Nothing else: *.com,
*example.com, www.*.com and *.*.example.com are invalid.
host_matches(pattern, host). Without *.: equal strings. With *.base: host ends with
base, the byte before it is ., and something comes before that .. So *.example.com
matches www.example.com and a.b.example.com, and does not match example.com,
badexample.com or .example.com. Use strip_suffix twice; do not index.
url_host. In this order; any failure is None:
- At most
MAX_URLbytes. strip_prefix("https://"). Lowercase only.- The host is the longest run of
a-z,0-9,.,-at the start of what is left. Find its end withfind(|c| !allowed(c)), take it withsplit_at_checked(stable since Rust 1.80; returnsOption). It must passvalid_host. - What follows the host may start with
:443; strip it if so. Any other port fails at step 5. - What is left must be empty, or
/followed only by bytes0x21..=0x7e(printable ASCII, no space).
Because the host ends at the first byte that cannot be in a host name, userinfo
(https://user@example.com/), other ports, ? or # straight after the host, [::1] and
uppercase all fail without a rule of their own.
Steps
- 1. Copy.
git switch m3a, thencp docs/plans/M3a/files/crates/brokerd/tests/args.rs crates/brokerd/tests/ - 2. See the test fail.
cargo test -p brokerd --test args. Expected: it does not compile. - 3. Write
args.rsand addpub mod args;tolib.rs. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p brokerd --test args. Expected:13 passed. - 5. Run the gate.
make gate. Expected last line:gate: ok. - 6. Log and commit.
git add crates/brokerd docs/implementer-log.md && git commit
Done when
cargo test -p brokerd --test argsreports 13 passed;make gateprintsgate: ok.
Stop and report if
- A test wants a path to be cleaned up (
//collapsed,..resolved) instead of refused. - You find you need a URL or host-name crate. None is allowed; the rules above are the whole job.