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>
138 lines
6.5 KiB
Markdown
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.
|