Repository navigation
fix(security): pass the passthrough token via a per-call kubeconfig, never argv - #107
Open
braghettos wants to merge 1 commit into
Open
braghettos wants to merge 1 commit into
braghettos wants to merge 1 commit into
Conversation
…never argv With TOKEN_PASSTHROUGH=true the caller's bearer token was appended to the kubectl command line as `--token <jwt>`. The joined argument list is the Operation of the ToolError returned when a command fails, so the token was copied into the tool result sent to the MCP client and into span status and exception events. It was also visible in the container's process list, and in the cache key that cached commands log at info level and put on spans. The CommandBuilder no longer puts a token on argv. For a call that carries one, Execute writes a minimal kubeconfig into a fresh 0700 temporary directory and passes it with --kubeconfig; the directory is removed when the call returns (success, failure, cancellation or deadline). The kubeconfig is derived from the configured one (explicit path, $KUBECONFIG or ~/.kube/config, else the in-cluster environment): the selected context's cluster entry is copied unchanged and the context namespace is kept. Its only user entry is `tokenFile:` pointing at a 0600 file next to it, so the token is neither on argv nor inside the kubeconfig. No credential or impersonation setting of the base user is carried over. Before, `--token` only overrode the token: a base user with a client certificate still sent it, and the API server could authenticate the call as that identity instead of the caller. The builder's flags are now placed before the first "--", so options such as --kubeconfig take effect for `kubectl exec ... -- cmd` and `kubectl run ... -- cmd` instead of being passed to the remote command. The cache key of a token-carrying command holds a per-process HMAC fingerprint of the token, which keeps results per identity without exposing the token. Defense in depth, a new internal/redact package: argument lists are redacted (values of --token, --kube-token, --password, --client-key, inline forms, and any key=value whose key matches token, password or secret) before they are logged or used as an error Operation; ToolError.Error, ToMCPResult, the tool span status and recorded exceptions, telemetry.RecordError and the shell executor's failure log redact the same patterns, plus bearer credentials, in free text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016RAxvkp7wRXwXDx4Lx8STi Signed-off-by: Diego Braga <diego.braga86@gmail.com>
braghettos
added a commit
to krateo-platformops/installer
that referenced
this pull request
Nov 11, 2026
…failed call (#829) Security priority. On a failed kubectl call the per-user tool path returned the user's JWT in the tool result (kagent-dev/tools#107). All 98 stored tokens were already expired; its owner redacts the session store separately. The per-user tool server now comes from a PRIVATE krateo-agentiko package pinned BY DIGEST ONLY, ghcr.io/krateo-agentiko/kagent-tools@sha256:6308035d...230c, under the new key perUserRbac.toolImage. There is no tag field at all, so no stale tag can win over the digest -- the trap that defeated the ClickHouse digest pin. repository and digest are deliberately NOT set in componentValues: the chart default IS the pin, and a second source would re-create that hazard. inst.privateImageComponents gains {name: kagent, path: [perUserRbac, toolImagePullSecrets]} so the existing registryAuth-derived dockerconfigjson pulls the private image with no manual ServiceAccount patch. 057 already has it: krateo-registry-image-pull, covering host ghcr.io, which is the same host as the new package, and inst.imagePullAuths dedupes one auths entry per host. 0.1.21 IS DELIBERATELY SKIPPED, and the reason is the point of this commit. 0.1.21 carried the pull secret INSIDE toolImage, whose schema requires [repository, digest]. crdgen preserves chart `required` into the composition CRD -- verified with krateo-audit-worm as a control, whose 5 chart-level blocks appear verbatim as .spec.* required in both its served versions -- and the image-pull injection CREATES each parent object while setting only the leaf. So the installer would have written toolImage: {pullSecrets: [...]} with neither required sibling, and the apiserver would have REJECTED the Kagent CR outright on any cluster with registryAuth enabled, which includes 057. Its owner moved the key to the sibling toolImagePullSecrets in 0.1.22 so the injection never touches toolImage. The injected shape was checked against 0.1.22's own schema before pinning: {enabled: true, toolImagePullSecrets: [{name: krateo-registry-image-pull}]} has no unknown keys, satisfies the item's required [name], and does not construct toolImage. perUserRbac itself carries no `required`, and the chart has no root `required`, so nothing here can wedge the umbrella. No componentValues widening is needed: the injection writes onto the composition spec AFTER the componentValues merge, so it is validated by the component's own crdgen CRD and never passes through the installer's componentValues schema. Asserted: 85 other pins byte-identical; the privateImageComponents list parses to exactly 8 entries with every pre-existing path unchanged. The first attempt at that edit matched a PREFIX of the agentgateway-policies uiTokenHop entry and spliced the new block in front of its `- image`/`- pullSecrets` lines, orphaning them onto kagent; caught by parsing the rendered YAML rather than trusting the diff, and re-anchored on the following entry. Claude-Session: https://claude.ai/code/session_016RAxvmbdtWAFW9j2vWsv7a Co-authored-by: Diego Braga <diego.braga@krateo.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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
Security fix for
TOKEN_PASSTHROUGH=true. The k8s tools passed the caller's bearer token to kubectl as--token <token>on the command line. When a command failed, the joined arguments became theOperationof the returnedToolError, so the token ended up in:A second problem showed up during testing.
--tokenonly overrides the token in the configured kubeconfig. If that kubeconfig's user also has a client certificate, kubectl sends both, and the API server can authenticate the request as the certificate's identity instead of the caller. In that setup, passthrough did not limit what a caller could do.Change
Token off argv (
internal/commands).CommandBuilder.Buildno longer emits a token. For a call with a token,Executewrites a per-call kubeconfig to a fresh0700temporary directory, passes it with--kubeconfig, and removes the directory when the call returns, whether it succeeded, failed, was cancelled or hit its deadline. The kubeconfig is derived from the configured one: the explicit path, else$KUBECONFIGor~/.kube/config, else the in-cluster environment (KUBERNETES_SERVICE_HOST/PORT, service account CA and namespace).proxy-url, and so on). The context namespace is kept.tokenFile:, pointing at a0600file in the same directory. Nothing else from the base user is copied: no client certificate or key, exec plugin, auth provider, basic auth, or impersonation (as,as-groups,as-uid,as-user-extra).tokenFileis used instead of an inlinetoken:so the kubeconfig itself never contains the token. A CLI that prints its kubeconfig, includingkubectl config view --raw, shows only the file's path.Related fixes in the builder.
--namespace,--kubeconfig, ...) now go before the first--. Before, akubectl exec <pod> -- <cmd>call (and thekubectl run ... -- sleepused by the connectivity check) got them after--: they were passed to the remote command and kubectl ignored them.Defense in depth (new
internal/redact).redact.Argscovers the value after--token,--kube-token,--passwordand--client-key, their--flag=valueforms, and any argument containing akey=valuewhose key matches(?i)(token|password|secret). It is applied to logged arguments and to theOperationof command errors.logger.RedactArgsForLognow delegates to it.redact.Stringapplies the same patterns, plusBearer <credential>, to free text. It is applied inToolError.Error,ToolError.ToMCPResult, the tool span status and recorded exception inToolMiddleware,telemetry.RecordError, and the shell executor's failure log (error and output).Behaviour notes
k8s_get_cluster_configuration(kubectl config view) now shows the per-call kubeconfig: one cluster and context, userkagent-token-passthroughwith atokenFile. Before, it showed the server's whole kubeconfig.$TMPDIR(/tmpby default). The Helm chart already mounts anemptyDirthere.--kubeconfig(kubectl, helm, istioctl, cilium).Testing
make lint: 0 issues.go vet ./...passes.go test -tags=test ./pkg/... ./internal/... ./cmd/...passes.-racepasses on the changed packages.TestBearerTokenPassthroughcases now assert this, through an executor that reads the per-call kubeconfig while the command runs);0600in a0700directory, hold the token, and are gone after success, failure, cancellation and deadline;ToolMiddlewareand the k8s handler;--.TOKEN_PASSTHROUGH=true, a ServiceAccount token that may list pods in one namespace, and a kubectl wrapper onPATHthat records argv:Forbiddenmessage.kubectl -v=9masks theAuthorizationheader;🤖 Generated with Claude Code
https://claude.ai/code/session_016RAxvkp7wRXwXDx4Lx8STi