Repository navigation
feat(contract): agents, their approval signature and roster entry - #1642
Conversation
Names and wire for agents: an assistant through MCP, Sourceful support for one ticket, and the release check. docs/agents.md is the normative text; Core enforces it in a later pull request. - Registry copy: roles agent, support and release; scopes for logs, config, diagnostics, capped holds, owner-confirmed requests and update requests; an agent scope on each op; access.agents; eight refusals; push kinds agent.added and agent.request. - gencontract reads the ops' agent scope into RegistryAgentOps, emits role names into go/protocol for FTW Cloud, and counts hold and request scopes as writes. - go/protocol/agents.go: the agent messages, limits, and the approval signature: ECDSA P-256 over deterministic CBOR, with golden vectors in testdata/approval-vectors.json for the phone and FTW Cloud. - cloudwire: a roster Device may carry an agent grant (scopes, ops, caps, expiry, principal). Envelopes may carry by, the staff id on a support call. - appenroll refuses an agent's role on a pairing code or a role change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ce0e361afa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| r := new(big.Int).SetBytes(sig[:32]) | ||
| s := new(big.Int).SetBytes(sig[32:]) | ||
| if !ecdsa.Verify(key, digest[:], r, s) { |
There was a problem hiding this comment.
Canonicalize ECDSA signatures before replay tracking
ecdsa.Verify accepts both (r, s) and (r, N-s) for the same P-256 approval, so a party that obtains an accepted request can replace the final 32 signature bytes and produce a second valid signature over identical approval bytes. Because the contract says the box remembers accepted signatures to reject replays, tracking the raw 64-byte value can therefore be bypassed; reject high-S signatures or canonicalize them before verification/replay storage (and add a vector for the alternate-S form).
Useful? React with 👍 / 👎.
…ollows the build The grant offer is a type FTW Cloud's consent page and the box page share. Request routes on the box page move to /api/agent-requests, config.get reads one section, every read fits one bulk frame, and E_RATE_LIMITED carries retryAfterMs in the error body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent contract (srcfl/ftw#1642) has FTW Cloud sign each grant offer the consent page hands the owner's app, with a key the app pins. It lives in the keys part, apart from the agent keys: one P-256 key, made at the first start, sealed like an agent key under its own context, kept in memory. It signs only the deterministic CBOR ["ftw.offer.v1", offer]. VerifyOffer is the app's check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srcfl/ftw#1642 (pinned at eeb3a6aa until it merges) defines agents. This follows it: - Scopes are the registry's constants. An agent row is approved only as the roster lists it: role agent, a tunnel, its `agent` grant for the client the offer named, and an end still ahead. Support and release rows are not this door's. - The grant is the roster's: scopes, ops, caps and end, refreshed with every roster, so an owner who narrows, widens or renews a grant changes the token at once. The service stops admitting an agent at its end by its own clock. - The consent page builds the grant offer (key, role, client, clientId, scopes, the ops those scopes open, proposed caps, 30 days, the code; no home, since the owner picks it), has the keys part sign it, and puts it in the link's fragment and the QR. /oauth/requests?code= returns it once. /.well-known/ftw-offer-key serves the key the app pins. - verified means a client FTW Cloud knows, by its document's host; any other document client is shown by its host. - Tokens name their home, ftwat_<home>.<secret>, for shard routing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s off the phone path Following the agent contract (srcfl/ftw#1642, docs/agents.md): - The door: store.Agent is a principal for sessions the keys part opens. An agent row (a roster Device with `agent`) is admitted only as Agent, never as a phone (so never on /v1/app or /v1/app/once) and never by a token; a phone's row is never an Agent. The store stops admitting an agent at agent.expiresAtMs by its own clock, without waiting for a roster, and gives an agent nothing from the readable copy. - The reads: the box refuses api.req to agents, so each read tool now asks one of the box's reads for agents: live.get, plan.get (+ price.get for plan.explain), hist.query (tiles unpacked to points, null for MISSING), health.get, events.get, logs.get (level and lines), config.get (section), diag.run (clock, link, meter, planner, driver with its src), drivers.get. Answers pass through as data, CBOR to JSON's shapes, bytes as base64url, every string cleaned. An answer without an id goes to the one call in flight. - Pacing: 15 calls in a row, then 0.75 a second per home, so no rolling minute holds more than the box's 60 reads, and a quarter of the door's bytes. - Words for the new refusals: E_GRANT_EXPIRED, E_OP_NOT_GRANTED, E_CAP_EXCEEDED with the cap and its max, E_RATE_LIMITED with the wait, E_NEEDS_OWNER, E_NOT_FOUND, E_RANGE_TOO_LARGE, and a box without a clock. session.terminate "expired" says the access ran out. live.get and drivers.get are named here until go/protocol names them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the owner Following the agent contract (srcfl/ftw#1642, docs/agents.md): - battery.hold and ev.charge_now send one cmd each, for an op the grant's allowlist names and within its caps (holdS, maxW), checked here before the box and never loosened. A hold always has an end (hold_s is never 0, the operator's endless hold), so control ends when the assistant stops renewing; the service never renews a hold by itself. ev.charge_now with stop releases the charger's hold. Each cmd has a fresh UUIDv7, 30 s of validity in the box's uptime, and the control revision the box last named; a stale one is learnt from E_CONFLICT and tried once more. - goal.request sends agent.request {op, args, why} for site.mode.set, planner.prefs.set (safety_k, battery_export), loadpoint.surplus_only.set and loadpoint.soc.set, with the assistant's own reason; the box keeps it until an owner confirms it with Face ID. requests.list (agent.list) shows the agent's own grant and how each request ended. - Arguments are typed and bounded and refuse unknown fields, so text a model read in a log cannot become an instruction to a home. The report says only what the box confirmed, and the box's refusals (E_CAP_EXCEEDED with the cap, E_RATE_LIMITED with the wait, E_OP_NOT_GRANTED) come back in words. The official MCP inspector (2.3.0, CLI) lists all 15 tools and calls two through the whole path in TestInspectorSmoke, which runs when MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with initialize, so it exercises the earlier-era path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the owner Following the agent contract (srcfl/ftw#1642, docs/agents.md): - battery.hold and ev.charge_now send one cmd each, for an op the grant's allowlist names and within its caps (holdS, maxW), checked here before the box and never loosened. A hold always has an end (hold_s is never 0, the operator's endless hold), so control ends when the assistant stops renewing; the service never renews a hold by itself. ev.charge_now with stop releases the charger's hold. Each cmd has a fresh UUIDv7, 30 s of validity in the box's uptime, and the control revision the box last named; a stale one is learnt from E_CONFLICT and tried once more. - goal.request sends agent.request {op, args, why} for site.mode.set, planner.prefs.set (safety_k, battery_export), loadpoint.surplus_only.set and loadpoint.soc.set, with the assistant's own reason; the box keeps it until an owner confirms it with Face ID. requests.list (agent.list) shows the agent's own grant and how each request ended. - Arguments are typed and bounded and refuse unknown fields, so text a model read in a log cannot become an instruction to a home. The report says only what the box confirmed, and the box's refusals (E_CAP_EXCEEDED with the cap, E_RATE_LIMITED with the wait, E_OP_NOT_GRANTED) come back in words. The official MCP inspector (2.3.0, CLI) lists all 15 tools and calls two through the whole path in TestInspectorSmoke, which runs when MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with initialize, so it exercises the earlier-era path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its roster The agent contract puts the box's Noise static key in the roster it signs (`noise`, forwarded to srcfl/ftw#1642). RosterBoxKey reads it from the newest roster the registry holds, checked again with the home's key, so the service opens a session only to a key the box itself named. Today's uplink keeps unknown roster fields, so this works as soon as a box sends it; a box that names none takes no agents yet and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the owner Following the agent contract (srcfl/ftw#1642, docs/agents.md): - battery.hold and ev.charge_now send one cmd each, for an op the grant's allowlist names and within its caps (holdS, maxW), checked here before the box and never loosened. A hold always has an end (hold_s is never 0, the operator's endless hold), so control ends when the assistant stops renewing; the service never renews a hold by itself. ev.charge_now with stop releases the charger's hold. Each cmd has a fresh UUIDv7, 30 s of validity in the box's uptime, and the control revision the box last named; a stale one is learnt from E_CONFLICT and tried once more. - goal.request sends agent.request {op, args, why} for site.mode.set, planner.prefs.set (safety_k, battery_export), loadpoint.surplus_only.set and loadpoint.soc.set, with the assistant's own reason; the box keeps it until an owner confirms it with Face ID. requests.list (agent.list) shows the agent's own grant and how each request ended. - Arguments are typed and bounded and refuse unknown fields, so text a model read in a log cannot become an instruction to a home. The report says only what the box confirmed, and the box's refusals (E_CAP_EXCEEDED with the cap, E_RATE_LIMITED with the wait, E_OP_NOT_GRANTED) come back in words. The official MCP inspector (2.3.0, CLI) lists all 15 tools and calls two through the whole path in TestInspectorSmoke, which runs when MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with initialize, so it exercises the earlier-era path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blockers: - Only FTW Cloud's keys part opens an agent's session: agent rows carry tun false and no copy scopes, and the cloud admits them on no door. - Offers are signed by a pinned offer key (SignedOffer, OpenOffer), live at most ten minutes, carry clientHost and verified, leave home empty, and a support offer comes only from SupportClientID. - Agent holds: battery.hold and loadpoint.boost lose their agent scope; agent_args allowlists each op's arguments; holds carry their setter, an owner's hold wins, and revoke or expiry ends an agent's holds. - The box page needs the house password for every widening route, from loopback too, and drivers never reach the box. Signed bytes and wire shapes: - agent.widen signs the row's key; agent.confirm signs the agent, the op and the hash of the args; request ids are 128 bits; replay memory keys on the signed bytes. - by is staff:<id> or bot:<name>, and only a support session may send it. - The roster carries the box's Noise key (noise) for the keys part. - Typed reads (live.get, loadpoints.get, cars.get, drivers.get) and cursors on logs and events, listed in the registry's reads table. - approval.key.set takes prevSig; audit rows carry count; agent.decided tells an agent how a request ended; E_CLOCK_SKEW; push kinds agent.expiring, approval.key.set and owner.added. Vectors regenerated: approval, offer and roster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srcfl/ftw#1642 (pinned at eeb3a6aa until it merges) defines agents. This follows it: - Scopes are the registry's constants. An agent row is approved only as the roster lists it: role agent, a tunnel, its `agent` grant for the client the offer named, and an end still ahead. Support and release rows are not this door's. - The grant is the roster's: scopes, ops, caps and end, refreshed with every roster, so an owner who narrows, widens or renews a grant changes the token at once. The service stops admitting an agent at its end by its own clock. - The consent page builds the grant offer (key, role, client, clientId, scopes, the ops those scopes open, proposed caps, 30 days, the code; no home, since the owner picks it), has the keys part sign it, and puts it in the link's fragment and the QR. /oauth/requests?code= returns it once. /.well-known/ftw-offer-key serves the key the app pins. - verified means a client FTW Cloud knows, by its document's host; any other document client is shown by its host. - Tokens name their home, ftwat_<home>.<secret>, for shard routing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s without tun srcfl/ftw#1642 at b7d1cc80 (pinned until it merges): - The offer is protocol.GrantOffer with clientHost (the metadata document's host, or a registered client's redirect host), verified and exp (the request's end, at most ten minutes), sent as protocol.SignedOffer {o, kid, sig}; protocol.OpenOffer opens it with the key pinned by keys.OfferKid. Only loadpoint.hold is a hold op now. - Agent rows carry tun false: approval needs the agent grant, the role, the client and an end ahead, not a tunnel. - The code lookup answers any origin, as the box's page fetches it from the owner's browser; the offer key's endpoint names its kid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s off the phone path Following the agent contract (srcfl/ftw#1642, docs/agents.md): - The door: store.Agent is a principal for sessions the keys part opens. An agent row (a roster Device with `agent`) is admitted only as Agent, never as a phone (so never on /v1/app or /v1/app/once) and never by a token; a phone's row is never an Agent. The store stops admitting an agent at agent.expiresAtMs by its own clock, without waiting for a roster, and gives an agent nothing from the readable copy. - The reads: the box refuses api.req to agents, so each read tool now asks one of the box's reads for agents: live.get, plan.get (+ price.get for plan.explain), hist.query (tiles unpacked to points, null for MISSING), health.get, events.get, logs.get (level and lines), config.get (section), diag.run (clock, link, meter, planner, driver with its src), drivers.get. Answers pass through as data, CBOR to JSON's shapes, bytes as base64url, every string cleaned. An answer without an id goes to the one call in flight. - Pacing: 15 calls in a row, then 0.75 a second per home, so no rolling minute holds more than the box's 60 reads, and a quarter of the door's bytes. - Words for the new refusals: E_GRANT_EXPIRED, E_OP_NOT_GRANTED, E_CAP_EXCEEDED with the cap and its max, E_RATE_LIMITED with the wait, E_NEEDS_OWNER, E_NOT_FOUND, E_RANGE_TOO_LARGE, and a box without a clock. session.terminate "expired" says the access ran out. live.get and drivers.get are named here until go/protocol names them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its roster The agent contract puts the box's Noise static key in the roster it signs (`noise`, forwarded to srcfl/ftw#1642). RosterBoxKey reads it from the newest roster the registry holds, checked again with the home's key, so the service opens a session only to a key the box itself named. Today's uplink keeps unknown roster fields, so this works as soon as a box sends it; a box that names none takes no agents yet and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tunnel srcfl/ftw#1642 at b7d1cc80: - The store admits an agent row, which now has tun false and empty copy scopes, only as store.Agent; a phone row still needs a tunnel or the copy, and never passes as an agent. - RosterBoxKey reads the typed cloudwire.Roster.Noise. - Each read tool's scope is the registry's (protocol.RegistryReads). chargers.list (loadpoints.get) and cars.list (cars.get) are new; events.list and logs.tail take before and after cursors and say whether more remain. The box names chargers and cars by its agent ids, which the control tools take back. - An answer comes back only by its request's id; agent.decided, pushed to an open session, answers no call. - E_CLOCK_SKEW in words; E_NEEDS_OWNER says whether a durable op belongs in goal.request or an owner's hold holds the charger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the owner Following the agent contract (srcfl/ftw#1642 at b7d1cc80, docs/agents.md): - ev.charge_now sends loadpoint.hold, the one hold op open to agents, or its clear with stop: only when the grant's allowlist names it, with the arguments protocol.RegistryAgentArgs allows an agent (id, power_w up to maxW, hold_s from 1 to holdS, phase_mode, clear), each of its kind and range, checked here before the box and never loosened. hold_s is never 0, the operator's endless hold; the service never renews a hold by itself, and an owner's own hold wins (E_NEEDS_OWNER, in words). Each cmd has a fresh UUIDv7, 30 s of validity in the box's uptime, and the control revision the box last named; a stale one is learnt from E_CONFLICT and tried once more. battery.hold is gone: the contract no longer opens it to agents. - goal.request sends agent.request {op, args, why} for site.mode.set, planner.prefs.set (safety_k 0 to 2, battery_export), loadpoint.surplus_ only.set and loadpoint.soc.set, held to the same registry rules, with the assistant's own reason; the box keeps it until an owner confirms it with Face ID. requests.list (agent.list) shows the agent's own grant and how each request ended. - Arguments are typed and refuse unknown fields, so text a model read in a log cannot become an instruction to a home. The report says only what the box confirmed. The official MCP inspector (2.3.0, CLI) lists all 16 tools and calls two through the whole path in TestInspectorSmoke, which runs when MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with initialize, so it exercises the earlier-era path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gent key Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit b952561)
TestOneDeviceCannotStarveTheOthersOfHandshakes gave twelve handshakes two seconds; under -race on a loaded runner one came late. It now waits for the budget to be spent, then half a second for any answer past it. TestTunDoesNotSpendTheCopysBudget read the counts before the last tun was counted, and failed if a heartbeat went meanwhile; it now waits for the count and checks that the tunnel's bytes never land on the copy's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OfferKid is the first 16 characters of base64url(SHA-256(SEC1 point)), as FTW Cloud's keys part names its offer key. The offer vector uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rKid lands srcfl/ftw#1642 now derives an offer key's kid the same way keys.OfferKid does, and Core pins offer keys as a list of kid:pub. A test holds the two derivations equal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the owner Following the agent contract (srcfl/ftw#1642 at b7d1cc80, docs/agents.md): - ev.charge_now sends loadpoint.hold, the one hold op open to agents, or its clear with stop: only when the grant's allowlist names it, with the arguments protocol.RegistryAgentArgs allows an agent (id, power_w up to maxW, hold_s from 1 to holdS, phase_mode, clear), each of its kind and range, checked here before the box and never loosened. hold_s is never 0, the operator's endless hold; the service never renews a hold by itself, and an owner's own hold wins (E_NEEDS_OWNER, in words). Each cmd has a fresh UUIDv7, 30 s of validity in the box's uptime, and the control revision the box last named; a stale one is learnt from E_CONFLICT and tried once more. battery.hold is gone: the contract no longer opens it to agents. - goal.request sends agent.request {op, args, why} for site.mode.set, planner.prefs.set (safety_k 0 to 2, battery_export), loadpoint.surplus_ only.set and loadpoint.soc.set, held to the same registry rules, with the assistant's own reason; the box keeps it until an owner confirms it with Face ID. requests.list (agent.list) shows the agent's own grant and how each request ended. - Arguments are typed and refuse unknown fields, so text a model read in a log cannot become an instruction to a home. The report says only what the box confirmed. The official MCP inspector (2.3.0, CLI) lists all 16 tools and calls two through the whole path in TestInspectorSmoke, which runs when MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with initialize, so it exercises the earlier-era path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… count Handshakes run side by side and a device keeps at most four unproved sessions, so on a loaded runner one could be evicted before it answered and the test saw 11 of 12. The property is that a device never gets more than its budget and another phone still gets in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s off the phone path Following the agent contract (srcfl/ftw#1642, docs/agents.md): - The door: store.Agent is a principal for sessions the keys part opens. An agent row (a roster Device with `agent`) is admitted only as Agent, never as a phone (so never on /v1/app or /v1/app/once) and never by a token; a phone's row is never an Agent. The store stops admitting an agent at agent.expiresAtMs by its own clock, without waiting for a roster, and gives an agent nothing from the readable copy. - The reads: the box refuses api.req to agents, so each read tool now asks one of the box's reads for agents: live.get, plan.get (+ price.get for plan.explain), hist.query (tiles unpacked to points, null for MISSING), health.get, events.get, logs.get (level and lines), config.get (section), diag.run (clock, link, meter, planner, driver with its src), drivers.get. Answers pass through as data, CBOR to JSON's shapes, bytes as base64url, every string cleaned. An answer without an id goes to the one call in flight. - Pacing: 15 calls in a row, then 0.75 a second per home, so no rolling minute holds more than the box's 60 reads, and a quarter of the door's bytes. - Words for the new refusals: E_GRANT_EXPIRED, E_OP_NOT_GRANTED, E_CAP_EXCEEDED with the cap and its max, E_RATE_LIMITED with the wait, E_NEEDS_OWNER, E_NOT_FOUND, E_RANGE_TOO_LARGE, and a box without a clock. session.terminate "expired" says the access ran out. live.get and drivers.get are named here until go/protocol names them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its roster The agent contract puts the box's Noise static key in the roster it signs (`noise`, forwarded to srcfl/ftw#1642). RosterBoxKey reads it from the newest roster the registry holds, checked again with the home's key, so the service opens a session only to a key the box itself named. Today's uplink keeps unknown roster fields, so this works as soon as a box sends it; a box that names none takes no agents yet and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tunnel srcfl/ftw#1642 at b7d1cc80: - The store admits an agent row, which now has tun false and empty copy scopes, only as store.Agent; a phone row still needs a tunnel or the copy, and never passes as an agent. - RosterBoxKey reads the typed cloudwire.Roster.Noise. - Each read tool's scope is the registry's (protocol.RegistryReads). chargers.list (loadpoints.get) and cars.list (cars.get) are new; events.list and logs.tail take before and after cursors and say whether more remain. The box names chargers and cars by its agent ids, which the control tools take back. - An answer comes back only by its request's id; agent.decided, pushed to an open session, answers no call. - E_CLOCK_SKEW in words; E_NEEDS_OWNER says whether a durable op belongs in goal.request or an owner's hold holds the charger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the owner Following the agent contract (srcfl/ftw#1642 at b7d1cc80, docs/agents.md): - ev.charge_now sends loadpoint.hold, the one hold op open to agents, or its clear with stop: only when the grant's allowlist names it, with the arguments protocol.RegistryAgentArgs allows an agent (id, power_w up to maxW, hold_s from 1 to holdS, phase_mode, clear), each of its kind and range, checked here before the box and never loosened. hold_s is never 0, the operator's endless hold; the service never renews a hold by itself, and an owner's own hold wins (E_NEEDS_OWNER, in words). Each cmd has a fresh UUIDv7, 30 s of validity in the box's uptime, and the control revision the box last named; a stale one is learnt from E_CONFLICT and tried once more. battery.hold is gone: the contract no longer opens it to agents. - goal.request sends agent.request {op, args, why} for site.mode.set, planner.prefs.set (safety_k 0 to 2, battery_export), loadpoint.surplus_ only.set and loadpoint.soc.set, held to the same registry rules, with the assistant's own reason; the box keeps it until an owner confirms it with Face ID. requests.list (agent.list) shows the agent's own grant and how each request ended. - Arguments are typed and refuse unknown fields, so text a model read in a log cannot become an instruction to a home. The report says only what the box confirmed. The official MCP inspector (2.3.0, CLI) lists all 16 tools and calls two through the whole path in TestInspectorSmoke, which runs when MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with initialize, so it exercises the earlier-era path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agents and support reach the box only through FTW Cloud's tunnel, with a grant the owner approved: scopes, a per-grant op allowlist, caps, an end time, holds that end, durable changes as owner-confirmed requests, an audit log the owner reads, and logs and config redacted on the box. Built on the contract in #1642. Squashed from claude/agents-core onto master after #1642 merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or the owner Following the agent contract (srcfl/ftw#1642 at b7d1cc80, docs/agents.md): - ev.charge_now sends loadpoint.hold, the one hold op open to agents, or its clear with stop: only when the grant's allowlist names it, with the arguments protocol.RegistryAgentArgs allows an agent (id, power_w up to maxW, hold_s from 1 to holdS, phase_mode, clear), each of its kind and range, checked here before the box and never loosened. hold_s is never 0, the operator's endless hold; the service never renews a hold by itself, and an owner's own hold wins (E_NEEDS_OWNER, in words). Each cmd has a fresh UUIDv7, 30 s of validity in the box's uptime, and the control revision the box last named; a stale one is learnt from E_CONFLICT and tried once more. battery.hold is gone: the contract no longer opens it to agents. - goal.request sends agent.request {op, args, why} for site.mode.set, planner.prefs.set (safety_k 0 to 2, battery_export), loadpoint.surplus_ only.set and loadpoint.soc.set, held to the same registry rules, with the assistant's own reason; the box keeps it until an owner confirms it with Face ID. requests.list (agent.list) shows the agent's own grant and how each request ended. - Arguments are typed and refuse unknown fields, so text a model read in a log cannot become an instruction to a home. The report says only what the box confirmed. The official MCP inspector (2.3.0, CLI) lists all 16 tools and calls two through the whole path in TestInspectorSmoke, which runs when MCP_INSPECTOR names it; CI runs it with npx. It speaks 2025-11-25 with initialize, so it exercises the earlier-era path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(agents): enforce agent and support access in Core Agents and support reach the box only through FTW Cloud's tunnel, with a grant the owner approved: scopes, a per-grant op allowlist, caps, an end time, holds that end, durable changes as owner-confirmed requests, an audit log the owner reads, and logs and config redacted on the box. Built on the contract in #1642. Squashed from claude/agents-core onto master after #1642 merged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agents): log cursors, phone reads, clock rules, doc Typed reads: logs.get pages with beforeMs and afterMs over the whole ring and says more; phones may send live.get, loadpoints.get, drivers.get and cars.get under their scopes. N4: the box page may reset the clock's mark, with the house password; every agent's grant ends with it. N5: approving a request needs a wall clock the box trusts. Doc: agent hello and sub, safe error args, holds in one step, the hold gap, security notices, audit folding, the clock reset, offer limits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(agents): the agent routes need sign-in with lan_auth on Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(agents): no sub through an agent's hello Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
srcfl/ftw#1642 added agent.added, agent.request, agent.expiring, approval.key.set and owner.added to the push catalogue; the app's copy missed them, so srcfl/ftw's 'the registry has not drifted from the app' check is red on master. The catalogue is copied byte for byte, with the English it holds and the Swedish beside it. These kinds are owners' news and come as the box's notice: as an event they reach viewers too, so an event that names one reads as generic. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
What
The contract for agents: an assistant through MCP at FTW Cloud, Sourceful support for one ticket, and the release check.
docs/agents.mdis the normative text. Core enforcement follows in a stacked PR; this one changes no behaviour except one refusal (below).docs/agents.md. Principals, scopes, which messages each scope opens, the op allowlist, caps, durable requests that wait for an owner, the seven messages with their fields and errors, the roster entry, expiry, the approval signature, approval on the box page, the grant offer FTW Cloud's consent page shows, push kinds, the audit log, and the numbers in one place.agent,support,release; six scopes; anagentscope on each op;access.agents; eight refusals; push kindsagent.added,agent.request.RegistryAgentOps, emits the role names intogo/protocol(so FTW Cloud stops spellingownerby hand), and countsholdandrequestscopes as writes.go/protocol/agents.go: the message shapes and limits, and the approval signature: ECDSA P-256 over the deterministic CBOR of["ftw.approval.v1", what, boxKey, deviceId, atMs, ...fields],r ‖ s. Golden vectors ingo/protocol/testdata/approval-vectors.jsonfor the phone and FTW Cloud.Devicemay carryagent {scopes, ops, caps, expiresAtMs, principal};docs/protocol-cloud.mdgains the tables. Envelopes may carryby, the staff id on a support call.appenrollrefuses an agent's role on a pairing code, a box code or a role change. Without it, the new roles would be valid phone roles the moment the registry names them.The box page's sign-in, as checked
docs/agents.md, "Approval on the box page", says it plainly: withapi.lan_authoff (the default) every LAN client is an owner with no sign-in, so widening routes need the box itself or the house password. With it on: argon2id, at least 10 characters, five wrong guesses lock sign-in for 30 s box-wide, a 12-hourHttpOnlySameSite=Strictcookie, but plain HTTP on the LAN and one shared password with no person behind it.Paired PRs
Contract.swift.Contract-pair: srcfl/ftw-webapp@claude/registry-agents
Tests
go test -p 2for./protocol/...(vectors, tamper, unsorted lists, envelopeby, allowlist against the document),apiauth,appenroll(new: an agent's role is never a phone's),appproto/...,appuplink,cloudlink,notifications: all pass.The changeset is
patch: names and a refusal, nothing a user sees.After the review (adversarial, at f557f6d)
Fixed before merge, the three blockers and everything that changes signed bytes or wire shapes:
tun: falseand no copy scopes; only FTW Cloud'skeyspart opens an agent's session, never/v1/app,/v1/app/onceor the relay (docs/protocol-cloud.md). Offers are signed (SignedOffer {o, kid, sig},protocol.OpenOffer) by a pinned offer key, live at most 10 minutes, carryclientHostandverified, leavehomeempty, and a support offer comes only fromSupportClientID.battery.holdandloadpoint.boostlose their agent scope. A newagent_argsblock allowlists each op's arguments, with labels (sovoltage,max_amps_per_phaseand the rest stay with owners). Holds carry their setter, an owner's hold wins (E_NEEDS_OWNER), an agent clears only its own,maxWis |W|, and revoke or expiry ends an agent's holds.agent.confirmsigns[reqId, agent, op, sha256(args)]; request ids are 128 bits; anatMsfrom before the box started is refused.agent_argsand the op's own checks.byisstaff:<id>orbot:<name>, refused from any session but support.agent.widensigns the row's key.Roster.noise); typed readslive.get,loadpoints.get,cars.get,drivers.getin a registryreadstable, cursors on logs and events, agent ids under the box's own key.approval.key.settakesprevSig, audit rows carrycount,agent.decided,E_CLOCK_SKEW, push kindsagent.expiring,approval.key.set,owner.added,ftw.update.requestmarked reserved,E_NOT_FOUNDworded for any id (the car contract can reuse it).Vectors regenerated: approval (widen and confirm bytes changed), a signed offer, and the roster (with
noiseand an agent row).🤖 Generated with Claude Code