fix(pier): refuse path values the mux never delivers and harden dispatch

- a path field must be required in the reflected schema (boot error), and
  a missing value, an empty, . or .. segment, or a / inside one segment is
  a tool error before dispatch (A-WR-01, A-WR-02)
- a failed guard dispatch logs its cause and answers a fixed tool text;
  only an invalid token and an unknown or deactivated account keep their
  own fixed texts (A-WR-08)
- path and query scalars decode with UseNumber, so integers above 2^53
  keep their digits (A-IN-04)
- the dispatched request carries the outer Host, X-Forwarded-Proto and
  X-Forwarded-Host (A-IN-05)
- an embedded struct on a GET or DELETE tool fails boot (A-IN-07)
- a dispatch whose guard list or principal kind contradicts the tool's
  guard is refused (A-IN-08)
This commit is contained in:
Jakub Zych
2026-10-23 16:05:32 +02:00
parent 240457291f
commit 104db9959f
5 changed files with 134 additions and 23 deletions

View File

@@ -118,9 +118,9 @@ What pier does with it at boot:
- **Routes.** `Method` and `Path` name one of the plugin's own routes exactly as registered, `{id}` segments included. A route no plugin registers fails boot. The call runs the route's full compiled chain: throttles, the guard, scope and permission middleware, body limits and the handler, the same code path as an HTTP request.
- **Guards.** A tool runs as exactly one guard: `user` (the frontend user, when a user plugin publishes that provider) or `backend` (the administrator, built in). When `Guard` is empty the route must carry the guard's token-accepting middleware (for example `user.api_token` or cabana's `backend`); a tool whose route authenticates inside its handler names its guard explicitly, as `create_post` does. A cookie-only or public route cannot become a tool by accident.
- **Permissions.** The route's middleware implies requirements the guard provider parses (scopes, permission codes); `Permissions` adds codes that filter the listing on top. The route still enforces on every call; the declaration only decides who sees the tool.
- **Input.** `Input` is the zero value of a struct. A `path:"id"` field fills the `{id}` segment, a `query:"q"` field goes to the query string, every other field forms the JSON body; a `jsonschema:"..."` tag is the field's description and a field without `omitempty` is required. pier reflects the input schema from the struct, validates every call's arguments against it, and forwards the body as the agent sent it minus the path and query keys, so an update route that checks key presence sees exactly what the agent set. A `GET` or `DELETE` tool may carry only path and query fields. `Output`, when set, is the struct the 2xx body matches and becomes the output schema.
- **Input.** `Input` is the zero value of a struct. A `path:"id"` field fills the `{id}` segment, a `query:"q"` field goes to the query string, every other field forms the JSON body; a `jsonschema:"..."` tag is the field's description and a field without `omitempty` is required. pier reflects the input schema from the struct, validates every call's arguments against it, and forwards the body as the agent sent it minus the path and query keys, so an update route that checks key presence sees exactly what the agent set. A `GET` or `DELETE` tool may carry only path and query fields, and an embedded struct on one fails boot (its promoted fields would be body fields). A `path:` field must be required: one with `omitempty` or `omitzero` fails boot. At call time pier passes a route only path values the HTTP mux could produce: a missing value, an empty, `.` or `..` segment, or a `/` inside a single-segment value is a tool error before the route runs. Numbers keep their literal digits in path and query values, so integer ids above 2^53 stay exact. `Output`, when set, is the struct the 2xx body matches and becomes the output schema.
- **Annotations.** `GET` is read-only and idempotent, `PUT` idempotent, `DELETE` destructive; `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` override. The read-only hint also decides what a read-only grant sees (below).
- **Results.** A 2xx body is returned as text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error carrying the route's own body, so the agent reads the real validation errors.
- **Results.** A 2xx body is returned as text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error carrying the route's own body, so the agent reads the real validation errors. A failure of the guard provider itself (for example a database error while resolving the principal) is the fixed tool error `pier: the tool could not be run for this caller`, logged server-side. The dispatched request carries the MCP request's `Host`, `X-Forwarded-Proto` and `X-Forwarded-Host`, so a route that builds absolute links builds them as over HTTP.
- **Prompts.** A `pact.MCPPrompt` is prefixed and filtered like a tool, names its guard, declares its arguments, and renders its messages in `Render`; required arguments are checked first. A prompt never dispatches a route.
- **Descriptions.** `Title` and `Description` are static text, at most 1024 characters, passed through unchanged. Keep them factual one-liners and never build them from data: tool text is read by the model, so data-derived text would be a prompt-injection surface.