Repository navigation
fix(gateway): N83 operator contracts — owner-set availability, no silent drops (R0 G8/G9/G11/G12) - #461
Merged
Conversation
…G11) operator-status marks an operator 'partial' until each capability has an availability and tells the operator to set mode=always or windows[], but no route could set it. PATCH and PUT /api/capabilities/:id do not exist, and a re-POST returns the existing row unchanged (rehearsal R0, calls.log:1038, 1086, 1110). New PUT /api/capabilities/:capId/availability: - The actor is the principal api-gate authenticated (the key's operatorId, else the SIWE userId), never a body field. No actor gives 401. - Only the capability's kernel operatorAddress, under WP-A's fold (never true for an empty id), may write; anyone else gets 403. - The /availability sub-path is deliberate: master's public allowlist matches /api/capabilities/:id for every method. - The body is strict zod (always, windows, cron or manual-claim; HH:mm or ISO windows; 5-field cron; an IANA-shaped timezone; no unknown keys), stored through serializeAvailability(). - delegate-to-agent and agentEndpoint are refused: that is a URL the gateway would POST to (SSRF). It merges after WP-A #326 (rule 6, owner guard), and should converge on WP-A's sameIdentity afterwards. Tests: 8/8, including an operator-status round trip. Mutation checks: with the owner check, the actor check or the delegate guard disabled, 2, 1 and 1 tests fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…pping them (N83, R0 G12) POST /api/capabilities silently dropped any key outside CreateCapabilityInput. R0 sent requirementsSchema and got a 201 that neither stored it nor said so (calls.log:371-393). A re-POST of an existing capability ignored every new field without a word. - The 201 now includes ignoredFields (sorted) when there are any. requirementsSchema adds a hint: typed parameters belong in the capability's CSD. - An existing capability (200, created:false) now lists every provided key except id, kernelId and type, with a note that POST never updates and a pointer to PUT /api/capabilities/:id/availability. - Persistence is unchanged. There is no DDL, since a new column is a parked schema change. Drafted by a sonnet subagent to kits' spec, then reviewed by kits. capabilities-create-contract.test.ts passes 4/4. With the fix reverted, 2 fail; the other 2 pin existing behaviour. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…rted firmware (N83, R0 G8) - A 400 missing_required_fields now carries missing[] (in kernelId, deviceId, type, adapterType order) and a message saying deviceId is operator-chosen and must stay stable across re-registrations. - A top-level firmware string is stored, trimmed and capped at 64 characters. "unknown" is used only when it is absent. Before, it was always "unknown" (setup.ts:614, R0 calls.log:511). Drafted by a sonnet subagent to kits' spec, then reviewed by kits. setup.test.ts passes 41/41, 4 of them new. With the fix reverted, 3 of the 4 new tests fail; the 4th pins the existing default. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…3, R0 G9) The invalid_body message said "PUT raw bytes", but there is no PUT route; POST with a binary Content-Type is the contract. The status code and error code are unchanged. Drafted by a sonnet subagent to kits' spec, then reviewed by kits. cid-storage.test.ts passes 16/16. The new test fails on the old message. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…compare; real tz and cron (astra pack 111 HIGH 1, HIGH 2, MEDIUM 3) Each was reproduced first on 2cc5358: - HIGH 1: with the real apiGate and POST /api/auth/provision, a key minted for the owner's email or wallet passed the owner check (200). - HIGH 2: a Kelvin-sign principal matched k@example.com under Unicode toLowerCase (200). - MEDIUM 3: cron 'x x x x x' and timezone 'Mars/Olympus' were stored (200). Fixes: - On master only a SIWE session proves identity; self-service keys can name any owner. A key-authenticated caller now gets 403 proven_identity_required until WP-A #326 binds keys to proven identity. - The owner compare is a validated EVM address compared ASCII-hex-case-insensitively, with no Unicode folding. An email-owned kernel can't be written here until verified-email identity exists. - Timezones must exist in the runtime's IANA database, and cron is checked value by value. With the fixes reverted, the 5 pack-111 tests fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…gnored (astra pack 111 MEDIUM 4) Reproduced first: re-POSTing cap-x with another kernelId and type returned created:false and ignoredFields [], although both values were discarded. They are now listed in ignoredFields plus conflicts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…0 (astra pack 111 MEDIUM 5)
Reproduced first: no body gave 500, because the handler destructured undefined. It now
falls back to {} and lists all four missing fields.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
LamaSu
marked this pull request as ready for review
September 30, 2026 02:56
LamaSu
added a commit
that referenced
this pull request
Oct 1, 2026
…e or padding sameIdentity folded both sides with String(id).trim().toLowerCase(). That lowercasing is Unicode-aware, so U+212A (KELVIN SIGN) folds to "k", and U+0130 folds to "i" plus a combining dot. The trim also strips NBSP, BOM, U+3000 and tab. So a principal whose id is a lookalike or a padded copy of an owner's id matched that owner. The same class was HIGH 2 on #461 (astra pack 111). The compare now lowercases A-Z only and keeps every other code point and all padding as is. A whitespace-only id still never matches. The doc comment says not to swap to WP-A's (#326) sameIdentity while it folds with trim().toLowerCase(). Reproduced first at dedb7f4: the new auth-actor test failed ("expected true to be false"), and a route test PUT from a Kelvin-sign lookalike of the template author returned 200 instead of 403. Both pass now. Two existing tests pinned trim semantics with padded ids; they now use ASCII case variants of the owner's id, since a padded id is a different principal. Tests: the full gateway suite passes (189 files, 3017 passed, 6 skipped). Gateway tsc exits 0. Mutations: 5 of 5 killed. Each of these fails a test: restoring the old fold, a Unicode toLowerCase without trim, the ASCII fold plus trim, no case fold at all, and dropping the blank guard. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This is board N83 (steward #3960). The first ADK dress rehearsal (R0,
returns/pcc-refvertical-work/rehearsal/R0-findings.md, G8/G9/G11/G12) found four places where an onboarding agent hit a gateway contract that contradicts itself. This PR fixes the code halves; adk documents what is true.Everything was reproduced before being fixed (goal-root review loop), against master
ac86a404and R0's own call log (rehearsal/r0-run-1/calls.log). The PR is built from master and does not stack on any open PR (WIP rule).operator-status.ts:184-197marks the operatorpartialand says to set availability. calls.log:1038/1086/1110:PATCHandPUT /api/capabilities/:idreturn 404, and a re-POSTreturns the existing row unchangedPUT /api/capabilities/:capId/availabilityfor the owner only (details below)requirementsSchemais sent, and the 201 neither stores it nor says so (CreateCapabilityInputhas no such field)ignoredFields.requirementsSchemagets a hint: typed parameters belong in the capability's CSD. A re-POST says it never updates. Persistence is unchanged: no DDL, since a new column is a parked schema changesetup.ts:547-549returns a baremissing_required_fields, andsetup.ts:614hard-codes firmware"unknown"(calls.log:511)missingfields and saysdeviceIdis operator-chosen and stable. A top-levelfirmwarestring is stored, trimmed and capped at 64 charactersstorage.ts:160says "PUT raw bytes", but there is no PUT routeG11: the availability route (security notes)
operatorId, else the SIWEuserId. A body field or header never counts. With no actor the route returns 401.operatorAddress, compared with WP-A's fold (trimmed, case-insensitive, never true for an empty id). Anyone else gets 403, and so does a capability whose kernel is missing./availabilityon purpose. Master's public allowlist matches/api/capabilities/:idfor every method, so a PATCH on that path would skip authentication. WP-A fix(gateway)!: WP-A scope hardening — wildcard is not money/admin authority, fail-closed scopes, identity binding (P0 MUST-CLOSE 6-10) #326 makes the allowlist method-aware.always,windows,cronandmanual-claim.HH:mm(overnight is allowed); a one-shot window uses ISO instants with start before end.serializeAvailability(), the canonical column shape.delegate-to-agentand anyagentEndpointare refused (400unsupported_mode). That endpoint is a URL the gateway would POST to, which invites SSRF. Nothing calls it today. This refusal is local to this route. Three other writers still store availability verbatim, all pre-existing and unchanged here:POST /api/capabilitiesat creation, the A2A author-integration skill (a2a-tasks.ts:707) and kernel announce (kernel.facade.ts:534).delegate-to-agentis also a documented mode (FOUR_SLOTS.md:149). So the real SSRF guard must sit at whatever first POSTs to anagentEndpoint: validate the target at CALL time, because DNS rebinding defeats a check made when the value is stored. Reported to the steward.sameIdentityonce those land.Found while reproducing, NOT changed here (reported to the steward)
POST /api/capabilitiesstores caller-suppliedassuranceTiersverbatim, defaulting to[0,1]. Tiers are therefore self-declared unless something else caps them. That is a trust question for evidence/gateway, not a contract bug.POST /api/operators/:slug/channels,PATCH/DELETE /api/operators/channels/:id) have no ownership check at all.Tests (run on the Spark, worktree
wt-kits-n83)packages/gateway: npx vitest run src/__tests__/capability-availability.test.tssetup.test.ts,cid-storage.test.ts,capabilities-create-contract.test.ts(new)packages/gateway: npx tsc --noEmit🤖 Generated with Claude Code