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