Repository navigation
fix(security): pass the passthrough token via a per-call kubeconfig, never argv - #107
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>
…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>
|
@braghettos thanks for both PRs! #103 and #107 both change Could you combine them into a single PR? You could stack #103 on top of #107 and close one of them, or squash both into one branch. Please keep the tests from both ( |
dimetron
left a comment
There was a problem hiding this comment.
LGTM. Reviewed for token leakage (argv, logs, errors, spans), temp-file permissions and cleanup, cache keying per token, and behaviour without a token. No issues found. #103 will need a rebase on internal/mcp/mcp.go and internal/telemetry/middleware.go after this merges.
Brings in kagent-dev#107 (per-call token kubeconfig, redaction). Conflict in internal/telemetry/middleware.go was the import block only: both redact (kagent-dev#107) and otelhttp (this PR) are kept. pkg/k8s/token_passthrough_test.go (kagent-dev#107) now looks for the tool span under its new name, "tools/call k8s_get_resources". Signed-off-by: Diego Braga <diego.braga86@gmail.com>
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