Files
kyleandClaude Opus 5 e3f37da232 Hand over the M3a plan: 22 tasks, their files, and the check record
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>
2026-09-18 23:45:43 -07:00

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 (add pub 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
  1. serde_json::from_str fails (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.
  2. path, and cwd when present, must pass valid_path, else ArgsError::Path(that path). This applies in all three tools that take a path: read_file, write_file and shell.
  3. url must give Some(host) from url_host, else ArgsError::Url(that url). Keep the host.
  4. command and content are 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:

  1. At most MAX_URL bytes.
  2. strip_prefix("https://"). Lowercase only.
  3. The host is the longest run of a-z, 0-9, ., - at the start of what is left. Find its end with find(|c| !allowed(c)), take it with split_at_checked (stable since Rust 1.80; returns Option). It must pass valid_host.
  4. What follows the host may start with :443; strip it if so. Any other port fails at step 5.
  5. What is left must be empty, or / followed only by bytes 0x21..=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, then cp 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.rs and add pub mod args; to lib.rs. Run cargo 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 args reports 13 passed; make gate prints gate: 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.