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>
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
-
Registry::m3a(): five entries in this order:clock(core: true,clock_schema()unchanged), thenread_file,write_file,shell,http_fetch(core: false). KeepRegistry::m2b(); change its doc comment to say it is the test registry (echoexists only inFakeToolsand the recorded conversations) and thatloopd servenever uses it. -
The four schemas. Every property is
{"type": "string", "description": "…"}. Use exactly these descriptions; the tests search them.Tool Description Properties (description) requiredread_fileRead a text file. Needs a grant from the owner for the path. path(The absolute path of the file.)["path"]write_fileWrite 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"]shellRun 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_fetchFetch 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"] }), } } -
clockis local. Indispatch, after thefind_toolandcall_toolchecks: ifname == CLOCK, returnDispatch::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 fromFakeTools::call;echostays. -
denial_text: onematchwith no_arm, exactly these sentences:Reason Sentence NoGrantDenied: no grant allows this call. GrantExpiredDenied: the grant for this call has expired. TaintTooHighDenied: this session has seen data too sensitive for this call. DeniedByGrantDenied: a grant forbids this call. ApprovalRefusedDenied: the owner refused this call. ApprovalExpiredDenied: the approval request expired without an answer. InvalidArgumentsDenied: the arguments are not valid for this tool. GrantsInvalidDenied: the grant files have an error; the owner has been told. AuditUnavailableDenied: the audit log cannot be written; the owner has been told. StateUnreadableDenied: this session's broker state is damaged; the owner has been told. -
run_callinturn.rsgains a last parameteron_event: &mut dyn FnMut(&TurnEvent);run_turnpasses its ownon_event. In theDispatch::Portarm, build theToolRequestas 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 }: firston_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 toolbrokerddecides on, notcall.name: for acall_toolcall the owner must seeread_file, because that is what grants are written for. The existingToolCallStartedandToolResultevents keepcall.name. TheDispatch::Localarm and the "already called" repeat path send neither new event. -
main.rs:Registry::m2b()becomesRegistry::m3a().FakeToolsstays until task 17. -
FakeTools::calltakes the new parameter and ignores it (_on_pending).
Steps
- 1. Copy.
git switch m3a, thencp docs/plans/M3a/files/crates/loopd/tests/{tools,turn,turn_broker}.rs crates/loopd/tests/andcp 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), thenturn.rs(rule 5), thenmain.rs. Runcargo 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 gateprintsgate: ok.
Stop and report if
the_m3a_registry_has_the_same_core_and_the_four_broker_toolsfails oncore_schemas(): the baseline would change, and that must not be worked around.- A test needs
loopdto parse or check a tool's arguments. It must not:brokerddoes that.