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

138 lines
6.5 KiB
Markdown

# 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
```rust
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.