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

7.9 KiB

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)

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:

    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:

    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.