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

149 lines
7.9 KiB
Markdown

# M3a task 16: the tool port, the registry and denials in `loopd`
**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Give loopd's tool port approvals, its own clock and plain denials`
## Goal
Get `loopd`'s tool path ready for `brokerd`, without a socket yet: the port can report a pending
approval, `clock` is answered by `loopd` itself, the registry offers the four tools `brokerd`
decides on, and a denial reaches the model as one fixed sentence and the owner as an event.
Nothing here may change an earlier byte of any session's prompt. It does not: the `tools` array
(`core_schemas()`) stays `clock`, `find_tool`, `call_tool` with the same schemas, and a test checks
that. The registry change only alters what a *new* `find_tool` call returns.
## Files
- Copy: `crates/loopd/tests/tools.rs`, `crates/loopd/tests/turn.rs`,
`crates/loopd/tests/turn_broker.rs`, `crates/loopd/tests/support/mod.rs`
- Modify: `crates/loopd/src/tools.rs`, `crates/loopd/src/turn.rs`, `crates/loopd/src/main.rs`,
`docs/implementer-log.md`
## Interfaces (`tools.rs`)
```rust
pub trait ToolPort: Send + Sync {
/// Returns the final answer, never `PendingApproval`. If the broker says the call is waiting
/// for the owner, the port calls `on_pending` once and goes on waiting.
fn call(&self, request: &proto::ToolRequest, on_pending: &mut dyn FnMut(&Pending))
-> proto::ToolResponse;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pending { pub approval: u64, pub expires: proto::Timestamp }
pub const CLOCK: &str = "clock";
impl Registry { pub fn m3a() -> Registry; } // m2b() stays: the tests use it
pub fn read_file_schema() -> ToolSchema; // and write_file_, shell_, http_fetch_
pub fn denial_text(reason: proto::DenyReason) -> &'static str;
```
## Rules
1. **`Registry::m3a()`**: five entries in this order: `clock` (`core: true`, `clock_schema()`
unchanged), then `read_file`, `write_file`, `shell`, `http_fetch` (`core: false`). Keep
`Registry::m2b()`; change its doc comment to say it is the test registry (`echo` exists only in
`FakeTools` and the recorded conversations) and that `loopd serve` never uses it.
2. **The four schemas.** Every property is `{"type": "string", "description": "…"}`. Use exactly
these descriptions; the tests search them.
| Tool | Description | Properties (description) | `required` |
|---|---|---|---|
| `read_file` | Read a text file. Needs a grant from the owner for the path. | `path` (The absolute path of the file.) | `["path"]` |
| `write_file` | Write a text file, replacing it. Needs a grant from the owner for the path. | `path` (The absolute path of the file.), `content` (The whole new content of the file.) | `["path", "content"]` |
| `shell` | Run a shell command in a sandbox. Needs a grant from the owner. | `command` (The command line to run.), `cwd` (The absolute path of the directory to run it in.) | `["command"]` |
| `http_fetch` | Fetch an https URL. Needs a grant from the owner for the host. | `url` (The URL. It must start with https://.) | `["url"]` |
One of them in full, as the pattern for the rest:
```rust
pub fn shell_schema() -> ToolSchema {
ToolSchema {
name: "shell".to_string(),
description: "Run a shell command in a sandbox. Needs a grant from the owner.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The command line to run." },
"cwd": { "type": "string", "description": "The absolute path of the directory to run it in." }
},
"required": ["command"]
}),
}
}
```
3. **`clock` is local.** In `dispatch`, after the `find_tool` and `call_tool` checks: if
`name == CLOCK`, return `Dispatch::Local(proto::Timestamp::now().to_rfc3339())`, whatever the
arguments are and whatever the registry holds. The time is not authority and needs no broker.
It is volatile, which is fine here: a tool result is always the newest message. Remove the
`"clock"` arm from `FakeTools::call`; `echo` stays.
4. **`denial_text`**: one `match` with no `_` arm, exactly these sentences:
| Reason | Sentence |
|---|---|
| `NoGrant` | Denied: no grant allows this call. |
| `GrantExpired` | Denied: the grant for this call has expired. |
| `TaintTooHigh` | Denied: this session has seen data too sensitive for this call. |
| `DeniedByGrant` | Denied: a grant forbids this call. |
| `ApprovalRefused` | Denied: the owner refused this call. |
| `ApprovalExpired` | Denied: the approval request expired without an answer. |
| `InvalidArguments` | Denied: the arguments are not valid for this tool. |
| `GrantsInvalid` | Denied: the grant files have an error; the owner has been told. |
| `AuditUnavailable` | Denied: the audit log cannot be written; the owner has been told. |
| `StateUnreadable` | Denied: this session's broker state is damaged; the owner has been told. |
5. **`run_call` in `turn.rs`** gains a last parameter `on_event: &mut dyn FnMut(&TurnEvent)`;
`run_turn` passes its own `on_event`. In the `Dispatch::Port` arm, build the `ToolRequest` as
now, then:
```rust
let response = rt.port.call(&request, &mut |pending| {
on_event(&TurnEvent::ApprovalPending {
approval: pending.approval,
tool: request.tool.clone(),
expires: pending.expires,
});
});
```
Then match `response`. There are four arms and each one returns `(text, class, untrusted)`:
- `Result { content, class, untrusted, .. }`: as now.
- `Failed { message }`: as now, `"The tool failed: {message}"`, `Public`, `false`. No event.
- `Denied { reason }`: first `on_event(&TurnEvent::ToolDenied { name: request.tool.clone(),
reason })`, then `(denial_text(reason).to_string(), Public, false)`. The old
`"The call was denied: {reason:?}"` text goes away.
- `PendingApproval { .. }`: `"The tool failed: the tool broker gave no final answer"`,
`Public`, `false`, no event. A pending frame is never a final answer; a port that returns
one has failed, and that is not a decision about the call. Replace the old text.
Both events carry `request.tool`, the tool `brokerd` decides on, not `call.name`: for a
`call_tool` call the owner must see `read_file`, because that is what grants are written for.
The existing `ToolCallStarted` and `ToolResult` events keep `call.name`. The `Dispatch::Local`
arm and the "already called" repeat path send neither new event.
6. **`main.rs`**: `Registry::m2b()` becomes `Registry::m3a()`. `FakeTools` stays until task 17.
7. `FakeTools::call` takes the new parameter and ignores it (`_on_pending`).
## Steps
- [ ] **1. Copy.** `git switch m3a`, then
`cp docs/plans/M3a/files/crates/loopd/tests/{tools,turn,turn_broker}.rs crates/loopd/tests/` and
`cp docs/plans/M3a/files/crates/loopd/tests/support/mod.rs crates/loopd/tests/support/`
- [ ] **2. See the tests fail.** `cargo test -p loopd --test tools`. Expected: it does not compile.
- [ ] **3. Change `tools.rs`** (rules 1 to 4 and 7), then `turn.rs` (rule 5), then `main.rs`.
Run `cargo fmt --all`.
- [ ] **4. See the tests pass.**
`cargo test -p loopd --test tools --test turn --test turn_broker --test limits --test channel`.
Expected: `10 passed`, `6 passed`, `5 passed`, `9 passed`, `6 passed`.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit`
## Done when
- The five test files in step 4 report those counts, and `make gate` prints `gate: ok`.
## Stop and report if
- `the_m3a_registry_has_the_same_core_and_the_four_broker_tools` fails on `core_schemas()`: the
baseline would change, and that must not be worked around.
- A test needs `loopd` to parse or check a tool's arguments. It must not: `brokerd` does that.