diff --git a/.github/upstream-projects.yaml b/.github/upstream-projects.yaml index f7c001e5..74df9cb3 100644 --- a/.github/upstream-projects.yaml +++ b/.github/upstream-projects.yaml @@ -44,7 +44,7 @@ projects: - id: toolhive repo: stacklok/toolhive - version: v0.48.0 + version: v0.49.0 # toolhive is a monorepo covering the CLI, the Kubernetes # operator, and the vMCP gateway. It also introduces cross- # cutting features that land in concepts/, integrations/, diff --git a/docs/toolhive/guides-cli/ai-plugins.mdx b/docs/toolhive/guides-cli/ai-plugins.mdx index c2ac0a46..b860a8eb 100644 --- a/docs/toolhive/guides-cli/ai-plugins.mdx +++ b/docs/toolhive/guides-cli/ai-plugins.mdx @@ -248,22 +248,37 @@ thv ai-plugin push ghcr.io/my-org/plugins/my-plugin:v1.0.0 \ --identity-token "$IDENTITY_TOKEN" ``` +To sign with a cosign key pair on disk instead, pass `--key`: + +```bash +thv ai-plugin push ghcr.io/my-org/plugins/my-plugin:v1.0.0 \ + --key cosign.key +``` + +Set `COSIGN_PASSWORD` in the `thv serve` environment to decrypt an encrypted +key. Key-pair signing requires the locally discovered ToolHive server; a push +against a remote or manually configured API URL (for example, via +`TOOLHIVE_API_URL`) returns `403`. Use `--identity-token` or the default keyless +flow against those servers. + To publish without a signature, pass `--no-sign`: ```bash thv ai-plugin push ghcr.io/my-org/plugins/my-plugin:v1.0.0 --no-sign ``` -To install an unsigned plugin project-scoped, pass `--allow-unsigned` to -`thv ai-plugin install`. - -:::note +`--key`, `--identity-token`, and `--no-sign` are mutually exclusive. -Plugin push is keyless-only. If you need to sign with a cosign key pair, publish -a [skill](./skills-management.mdx) instead, which supports both keyless and -key-pair signing via `--key`. +To install a key-pair-signed plugin project-scoped, pass +`--public-key ` with the matching cosign public key on the first +install. ToolHive verifies the signature and pins the key in the project's +`toolhive.lock.yaml` so subsequent `thv ai-plugin sync` and +`thv ai-plugin upgrade` runs reuse it automatically. Distribute the public key +alongside the artifact. Keyless-signed plugins need no such out-of-band step, +since the signer identity is verifiable from the transparency-log entry. -::: +To install an unsigned plugin project-scoped, pass `--allow-unsigned` to +`thv ai-plugin install`. Push uses your existing container registry credentials (for example, from `docker login` or `podman login`). Authenticate before pushing. diff --git a/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx b/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx index b0c711c2..207489f7 100644 --- a/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx +++ b/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx @@ -761,15 +761,14 @@ For OAuth 2.0 servers that return identity in the token response itself, see #### Select the token endpoint auth method -When a client secret is configured, the embedded auth server sends the client -credentials to the upstream token endpoint using HTTP Basic auth -(`client_secret_basic`). This matches the RFC 7591 default for confidential -clients and covers most providers. Public clients without a secret use the -`none` method. +Pre-registered OAuth 2.0 upstream clients send credentials to the token endpoint +in the request POST body (`client_secret_post`) by default, whether or not a +client secret is configured. Public clients without a secret use the `none` +method. -Set `tokenEndpointAuthMethod: client_secret_post` on `oauth2Config` for -providers that require credentials in the request body instead of the -`Authorization` header: +Set `tokenEndpointAuthMethod: client_secret_basic` on `oauth2Config` for +providers that require credentials in the HTTP `Authorization` header instead of +the request body: ```yaml title="MCPExternalAuthConfig: token endpoint auth method" oauth2Config: @@ -780,18 +779,20 @@ oauth2Config: name: upstream-idp-secret key: client-secret # highlight-next-line - tokenEndpointAuthMethod: client_secret_post + tokenEndpointAuthMethod: client_secret_basic ``` Allowed values are `none`, `client_secret_basic`, and `client_secret_post`. DCR clients continue to use the method negotiated at registration time. -:::info[Changed in v0.48.0] +:::info[Changed in v0.49.0] -Pre-registered OAuth 2.0 upstream clients with a configured secret previously -sent credentials in the request body. They now default to `client_secret_basic`. -If your provider rejects Basic auth on the token endpoint (rare), set -`tokenEndpointAuthMethod: client_secret_post` to restore the previous behavior. +If you are upgrading from v0.48.0, the default for pre-registered OAuth 2.0 +upstream clients with a configured secret has changed back from +`client_secret_basic` to `client_secret_post`. If your provider only accepts +HTTP Basic auth on the token endpoint, set +`tokenEndpointAuthMethod: client_secret_basic` explicitly. Upgrades from v0.47.x +or earlier see no change in default behavior. ::: diff --git a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx index be045fff..2d0893d2 100644 --- a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx +++ b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx @@ -472,23 +472,25 @@ ToolHive obtains the client ID and secret from the DCR response. ### Select the token endpoint auth method -When a client secret is configured, ToolHive sends the credentials to the -upstream OAuth 2.0 token endpoint using HTTP Basic auth (`client_secret_basic`). -This matches the RFC 7591 default for confidential clients and covers most -providers. Public clients without a secret use the `none` method. - -Set `tokenEndpointAuthMethod: client_secret_post` on `oauth2Config` for -providers that require credentials in the request body instead of the -`Authorization` header. Allowed values are `none`, `client_secret_basic`, and +Pre-registered OAuth 2.0 upstream clients send credentials to the token endpoint +in the request POST body (`client_secret_post`) by default, whether or not a +client secret is configured. Public clients without a secret use the `none` +method. + +Set `tokenEndpointAuthMethod: client_secret_basic` on `oauth2Config` for +providers that require credentials in the HTTP `Authorization` header instead of +the request body. Allowed values are `none`, `client_secret_basic`, and `client_secret_post`. DCR clients continue to use the method negotiated at registration time. -:::info[Changed in v0.48.0] +:::info[Changed in v0.49.0] -Pre-registered OAuth 2.0 upstream clients with a configured secret previously -sent credentials in the request body. They now default to `client_secret_basic`. -If your provider rejects Basic auth on the token endpoint (rare), set -`tokenEndpointAuthMethod: client_secret_post` to restore the previous behavior. +If you are upgrading from v0.48.0, the default for pre-registered OAuth 2.0 +upstream clients with a configured secret has changed back from +`client_secret_basic` to `client_secret_post`. If your provider only accepts +HTTP Basic auth on the token endpoint, set +`tokenEndpointAuthMethod: client_secret_basic` explicitly. Upgrades from v0.47.x +or earlier see no change in default behavior. ::: diff --git a/docs/toolhive/integrations/aws-sts.mdx b/docs/toolhive/integrations/aws-sts.mdx index 8f87136f..43795ffd 100644 --- a/docs/toolhive/integrations/aws-sts.mdx +++ b/docs/toolhive/integrations/aws-sts.mdx @@ -341,8 +341,19 @@ awsSts: roleClaim: roles # look at the "roles" claim instead of "groups" ``` +The role claim must be a **string** or a **list of strings**. A bare string +matches when it equals a mapping's `claim` value exactly. Any other shape +(object, number, boolean, or null) is rejected: the request fails with +`403 Failed to determine IAM role` from the STS middleware, and vMCP outbound +STS calls fail with `failed to select IAM role`. Watch the proxy logs for +`role claim has unsupported shape, failing closed`, which identifies the +offending mapping. + +A missing role claim falls back to `fallbackRoleArn`. + For more complex matching logic, use CEL expressions in the `matcher` field -instead of `claim`: +instead of `claim`. Matcher expressions run against the raw claims map and are +not affected by the shape rule above: ```yaml roleMappings: diff --git a/docs/toolhive/reference/authz-policy-reference.mdx b/docs/toolhive/reference/authz-policy-reference.mdx index f06e0ff2..24ddc93e 100644 --- a/docs/toolhive/reference/authz-policy-reference.mdx +++ b/docs/toolhive/reference/authz-policy-reference.mdx @@ -26,6 +26,7 @@ automatically. | `Tool` | `Tool::""` | A tool resource (used for `tools/call`) | | `Prompt` | `Prompt::""` | A prompt resource (used for `prompts/get`) | | `Resource` | `Resource::""` | A data resource (used for `resources/read`). The URI is [sanitized](#resource-uri-sanitization) for Cedar compatibility | +| `Skill` | `Skill::""` | A skill resource (used for `skills/get`). The exact skill URI is used verbatim as the entity ID | | `FeatureType` | `FeatureType::""` | A feature category entity. Values: `tool`, `prompt`, `resource`. Not currently used for authorization; list operations are handled via [response filtering](#list-operation-filtering) | | `THVGroup` | `THVGroup::""` | A group membership entity. Used with Cedar's `in` operator for [group-based policies](#group-membership) | @@ -43,15 +44,16 @@ These actions are evaluated against your Cedar policies: | `Action::"call_tool"` | `tools/call` | Call a specific tool | | `Action::"get_prompt"` | `prompts/get` | Retrieve a specific prompt | | `Action::"read_resource"` | `resources/read` | Read a specific data resource | +| `Action::"get_skill"` | `skills/get` | Retrieve a specific skill | ### List operations -List methods (`tools/list`, `prompts/list`, `resources/list`, and -`resources/templates/list`) bypass request-level authorization entirely. -ToolHive allows the list request through and filters the response to include -only items the caller is authorized to access using the individual-access -actions above. See [List operation filtering](#list-operation-filtering) for -details. +List methods (`tools/list`, `prompts/list`, `resources/list`, +`resources/templates/list`, and `skills/list`) bypass request-level +authorization entirely. ToolHive allows the list request through and filters the +response to include only items the caller is authorized to access using the +individual-access actions above. See +[List operation filtering](#list-operation-filtering) for details. ### Always-allowed MCP methods @@ -224,6 +226,21 @@ these attributes: | `feature` | String | Always `"resource"` | | `arg_` | Varies | Request argument values | +### Skill get attributes (`skills/get`) + +When a client retrieves a skill, the resource entity (`Skill::`) has these +attributes: + +| Attribute | Type | Description | +| ----------- | ------ | ------------------------------ | +| `name` | String | The skill URI | +| `uri` | String | The skill URI (same as `name`) | +| `operation` | String | Always `"get"` | +| `feature` | String | Always `"skill"` | + +A `skills/get` request with a missing, empty, or non-string `uri` parameter, or +with duplicate `uri` members, is rejected before it reaches an authorizer. + ### Feature list attributes (list operations) :::info[Not currently used] @@ -470,10 +487,11 @@ permit( ## List operation filtering -List operations (`tools/list`, `prompts/list`, `resources/list`, and -`resources/templates/list`) bypass request-level authorization entirely. -ToolHive forwards the list request to the MCP server, then filters the response -to include only items the caller is authorized to access. +List operations (`tools/list`, `prompts/list`, `resources/list`, +`resources/templates/list`, and `skills/list`) bypass request-level +authorization entirely. ToolHive forwards the list request to the MCP server, +then filters the response to include only items the caller is authorized to +access. For each item in the list response, ToolHive runs a policy check using the corresponding individual-access action: @@ -484,12 +502,14 @@ corresponding individual-access action: | `prompts/list` | `Action::"get_prompt"` against each `Prompt::""` | | `resources/list` | `Action::"read_resource"` against each `Resource::""` | | `resources/templates/list` | `Action::"read_resource"` against each `Resource::""` built from the URI template | +| `skills/list` | `Action::"get_skill"` against each `Skill::""` | This means you don't need separate list policies. Your `call_tool`, -`get_prompt`, and `read_resource` policies automatically control what appears in -list responses. For resources and resource templates, the per-item check uses -the [sanitized](#resource-uri-sanitization) entity ID, while the original URI -(or URI template) remains available via the `resource.uri` attribute. +`get_prompt`, `read_resource`, and `get_skill` policies automatically control +what appears in list responses. For resources and resource templates, the +per-item check uses the [sanitized](#resource-uri-sanitization) entity ID, while +the original URI (or URI template) remains available via the `resource.uri` +attribute. Skill URIs are used verbatim as the entity ID. Filtered resource template responses are served with `Cache-Control: private, no-store` so a shared HTTP cache cannot replay one @@ -497,11 +517,12 @@ caller's filtered view to a different caller. :::note -Because list responses are filtered using `call_tool`, `get_prompt`, and -`read_resource` policies, an item only appears in a list response when the -corresponding individual-access policy permits it. For example, if no -`call_tool` policy permits a given tool, that tool won't appear in `tools/list` -responses. +Because list responses are filtered using `call_tool`, `get_prompt`, +`read_resource`, and `get_skill` policies, an item only appears in a list +response when the corresponding individual-access policy permits it. For +example, if no `call_tool` policy permits a given tool, that tool won't appear +in `tools/list` responses. A `skills/list` response with no matching `get_skill` +permit returns an empty list rather than a 403. ::: diff --git a/docs/toolhive/reference/cli/thv_ai-plugin_push.md b/docs/toolhive/reference/cli/thv_ai-plugin_push.md index b4081b83..7f821d73 100644 --- a/docs/toolhive/reference/cli/thv_ai-plugin_push.md +++ b/docs/toolhive/reference/cli/thv_ai-plugin_push.md @@ -17,8 +17,8 @@ Push a built AI-tool plugin to an OCI registry Push a previously built plugin artifact to a remote OCI registry. -Push signs keylessly by default. Use --no-sign to publish unsigned; plugin push -does not support key-pair signing and has no --key flag. +Push signs keylessly by default. Use --key to sign with a cosign key pair +instead, or --no-sign to publish unsigned. ``` thv ai-plugin push [reference] [flags] @@ -28,7 +28,8 @@ thv ai-plugin push [reference] [flags] ``` -h, --help help for push - --identity-token string OIDC identity token (or a path to a file containing one) for keyless signing. If omitted, one is acquired automatically: from the GitHub Actions OIDC token when running with id-token: write permission, otherwise via an interactive browser sign-in + --identity-token string OIDC identity token (or a path to a file containing one) for keyless signing. Mutually exclusive with --key. If omitted, one is acquired automatically: from the GitHub Actions OIDC token when running with id-token: write permission, otherwise via an interactive browser sign-in + --key string Path to a cosign private key to sign the pushed artifact. Requires the locally discovered ToolHive server; for a remote or manually configured API URL, use keyless signing. Encrypted keys are decrypted with COSIGN_PASSWORD read from the 'thv serve' process, which performs the signing. Consumers installing the result project-scoped must pass --public-key with the matching cosign public key the first time; distribute it alongside the artifact. Keyless signing needs no such out-of-band step, since the signer identity is verifiable from the artifact itself --no-sign Push without signing (consumers will need an explicit unsigned exception to install project-scoped) ``` diff --git a/static/api-specs/toolhive-api.yaml b/static/api-specs/toolhive-api.yaml index afd6c1d7..39c8a943 100644 --- a/static/api-specs/toolhive-api.yaml +++ b/static/api-specs/toolhive-api.yaml @@ -395,10 +395,10 @@ components: token_endpoint_auth_method: description: |- TokenEndpointAuthMethod selects how the client authenticates at the OAuth token - endpoint. When empty and a client secret is configured, client_secret_basic is - used, matching the RFC 7591 default for confidential clients. Set this to - client_secret_post only for providers that require credentials in the request body. - Public clients without a secret use the "none" method. + endpoint. When empty, credentials are sent in the request body (the historical + client_secret_post-shaped default). Set this to client_secret_basic explicitly + for providers that require HTTP Basic auth. Public clients without a secret use + the "none" method. type: string token_response_mapping: $ref: '#/components/schemas/authserver.TokenResponseMappingRunConfig' @@ -3574,15 +3574,24 @@ components: type: boolean type: object pkg_api_v1.pushPluginRequest: - description: Request to push a built plugin artifact. Exactly one of identity_token + description: Request to push a built plugin artifact. Exactly one of key, identity_token, or no_sign is required. properties: identity_token: description: |- IdentityToken is a short-lived OIDC identity token used for keyless - signing. Plugin signing is keyless-only: there is deliberately no key - field, because ToolHive cannot verify key-signed artifacts at install - time and would publish an uninstallable plugin (#6442) + signing, mutually exclusive with Key + type: string + key: + description: |- + Key is the path to a cosign private key, resolved on the server's + filesystem. Accepted only when the request carries the secret capability + from the owner-protected local server discovery file; other requests are + refused with 403, since honoring one would let an untrusted caller have + the server sign with any key it can read. Use IdentityToken when calling + a remote or manually configured server. Consumers installing the result + project-scoped must supply the matching public key on first use + (install's public_key). type: string no_sign: description: NoSign pushes without signing @@ -4482,6 +4491,8 @@ components: $ref: '#/components/schemas/registry.SkillPackage' type: array uniqueItems: false + provenance: + $ref: '#/components/schemas/registry.Provenance' repository: $ref: '#/components/schemas/registry.SkillRepository' status: @@ -4898,7 +4909,12 @@ components: type: string type: object storage.ACLUserRunConfig: - description: ACLUserConfig contains ACL user authentication configuration. + description: |- + ACLUserConfig contains ACL user authentication configuration. + A nil value is a valid no-auth configuration: the store connects without + credentials. A populated block whose password resolves to empty is a + misconfiguration (mis-keyed or unsynced secret) and is rejected rather + than silently downgraded to an unauthenticated connection. properties: password_env_var: description: PasswordEnvVar is the environment variable containing the Redis @@ -4920,8 +4936,14 @@ components: Mutually exclusive with SentinelConfig. type: string auth_type: - description: AuthType must be "aclUser" - only ACL user authentication is - supported. + description: |- + AuthType selects the Redis authentication mode. "aclUser" is the only + authenticated mode. Leave it empty, with a nil ACLUserConfig, for a + no-auth connection to a Redis/Valkey instance that has no authentication + configured. Setting AuthType to "aclUser" declares authenticated intent: + the conversion rejects that pairing with a nil ACLUserConfig rather than + downgrading to no-auth. Otherwise presence of ACLUserConfig is what + enables authentication. type: string cluster_mode: description: ClusterMode enables the Redis Cluster protocol. Requires Addr @@ -5150,7 +5172,7 @@ components: description: |- BuilderImage is the full image reference for the builder stage. An empty string signals "use the default for this transport type" during config merging. - Examples: "golang:1.26-alpine", "node:24-alpine", "python:3.14-slim" + Examples: "golang:1.27-alpine", "node:24-alpine", "python:3.14-slim" type: string runtime_env: additionalProperties: @@ -6191,6 +6213,12 @@ paths: /api/v1beta/plugins/push: post: description: Push a built plugin artifact to a remote registry + parameters: + - description: Local discovery capability (required with request.key) + in: header + name: X-Toolhive-Key-Signing-Capability + schema: + type: string requestBody: content: application/json: @@ -6215,6 +6243,12 @@ paths: schema: type: string description: Bad Request + "403": + content: + application/json: + schema: + type: string + description: Forbidden (key signing requires the local discovery capability) "404": content: application/json: @@ -6383,20 +6417,17 @@ paths: tags: - registry post: - description: Add a new registry - requestBody: - content: - application/json: - schema: - type: object + description: |- + This endpoint is retained for API compatibility, accepts no request body, and always returns 501 Not Implemented. + Custom registries are not currently supported. responses: "501": content: - application/json: + text/plain: schema: type: string description: Not Implemented - summary: Add a registry + summary: Add a registry (unavailable) tags: - registry /api/v1beta/registry/{name}: diff --git a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json index b95fa920..58c99e66 100644 --- a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json +++ b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json @@ -1422,7 +1422,7 @@ "type": "string" }, "tokenEndpointAuthMethod": { - "description": "TokenEndpointAuthMethod selects how the client authenticates at the OAuth token\nendpoint. When empty and a client secret is configured, client_secret_basic is\nused, matching the RFC 7591 default for confidential clients. Set this to\nclient_secret_post only for providers that require credentials in the request body.\nPublic clients without a secret use the \"none\" method.", + "description": "TokenEndpointAuthMethod selects how the client authenticates at the OAuth token\nendpoint. When empty, credentials are sent in the request body (the historical\nclient_secret_post-shaped default). Set this to client_secret_basic explicitly\nfor providers that require HTTP Basic auth. Public clients without a secret use\nthe \"none\" method.", "enum": [ "none", "client_secret_basic", diff --git a/static/api-specs/toolhive-crds/virtualmcpservers.schema.json b/static/api-specs/toolhive-crds/virtualmcpservers.schema.json index 3454571c..42c4b16e 100644 --- a/static/api-specs/toolhive-crds/virtualmcpservers.schema.json +++ b/static/api-specs/toolhive-crds/virtualmcpservers.schema.json @@ -1313,7 +1313,7 @@ "type": "string" }, "tokenEndpointAuthMethod": { - "description": "TokenEndpointAuthMethod selects how the client authenticates at the OAuth token\nendpoint. When empty and a client secret is configured, client_secret_basic is\nused, matching the RFC 7591 default for confidential clients. Set this to\nclient_secret_post only for providers that require credentials in the request body.\nPublic clients without a secret use the \"none\" method.", + "description": "TokenEndpointAuthMethod selects how the client authenticates at the OAuth token\nendpoint. When empty, credentials are sent in the request body (the historical\nclient_secret_post-shaped default). Set this to client_secret_basic explicitly\nfor providers that require HTTP Basic auth. Public clients without a secret use\nthe \"none\" method.", "enum": [ "none", "client_secret_basic",