Skip to content

fix(security): pass the passthrough token via a per-call kubeconfig, never argv - #107

Open
braghettos wants to merge 1 commit into
kagent-dev:mainfrom
braghettos:fix/token-off-argv
Open

braghettos wants to merge 1 commit into
kagent-dev:mainfrom
braghettos:fix/token-off-argv

Conversation

@braghettos

Copy link
Copy Markdown
Contributor

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 the Operation of the returned ToolError, so the token ended up in:

  • the tool result returned to the MCP client (and from there in the agent's context, its model provider's requests and its session history);
  • the tool span's status description and recorded exception;
  • the container's process list while kubectl ran;
  • the cache key of cached commands, which is logged at info level and set as a span attribute.

A second problem showed up during testing. --token only 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.Build no longer emits a token. For a call with a token, Execute writes a per-call kubeconfig to a fresh 0700 temporary 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 $KUBECONFIG or ~/.kube/config, else the in-cluster environment (KUBERNETES_SERVICE_HOST/PORT, service account CA and namespace).

  • The cluster entry of the selected context is copied unchanged (server, CA data or file, TLS settings, proxy-url, and so on). The context namespace is kept.
  • The only user entry is tokenFile:, pointing at a 0600 file 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).
  • tokenFile is used instead of an inline token: so the kubeconfig itself never contains the token. A CLI that prints its kubeconfig, including kubectl config view --raw, shows only the file's path.
  • Error messages name the configured kubeconfig, not the temporary path, which no longer exists by the time the error is read.

Related fixes in the builder.

  • The builder's flags (--namespace, --kubeconfig, ...) now go before the first --. Before, a kubectl exec <pod> -- <cmd> call (and the kubectl run ... -- sleep used by the connectivity check) got them after --: they were passed to the remote command and kubectl ignored them.
  • The cache key of a token-carrying command now includes a per-process HMAC-SHA256 fingerprint of the token instead of the token. Cached results stay separate per caller.

Defense in depth (new internal/redact).

  • redact.Args covers the value after --token, --kube-token, --password and --client-key, their --flag=value forms, and any argument containing a key=value whose key matches (?i)(token|password|secret). It is applied to logged arguments and to the Operation of command errors. logger.RedactArgsForLog now delegates to it.
  • redact.String applies the same patterns, plus Bearer <credential>, to free text. It is applied in ToolError.Error, ToolError.ToMCPResult, the tool span status and recorded exception in ToolMiddleware, telemetry.RecordError, and the shell executor's failure log (error and output).

Behaviour notes

  • Under passthrough, k8s_get_cluster_configuration (kubectl config view) now shows the per-call kubeconfig: one cluster and context, user kagent-token-passthrough with a tokenFile. Before, it showed the server's whole kubeconfig.
  • Under passthrough, a call fails before kubectl runs if no configuration can be resolved, or if the selected context or its cluster does not exist. The error does not contain the token.
  • The temporary files go to $TMPDIR (/tmp by default). The Helm chart already mounts an emptyDir there.
  • Only the k8s provider forwards the token today. The builder handles any CLI that accepts --kubeconfig (kubectl, helm, istioctl, cilium).

Testing

  • make lint: 0 issues. go vet ./... passes. go test -tags=test ./pkg/... ./internal/... ./cmd/... passes. -race passes on the changed packages.
  • New unit tests:
    • no token on argv for passthrough calls (the existing TestBearerTokenPassthrough cases now assert this, through an executor that reads the per-call kubeconfig while the command runs);
    • the per-call kubeconfig and token file have mode 0600 in a 0700 directory, hold the token, and are gone after success, failure, cancellation and deadline;
    • the derived user carries exactly one credential (the token file) and no impersonation, even when the base user has client certificates, an exec plugin, basic auth and impersonation. The cluster fields are preserved;
    • context selection, relative CA path resolution, in-cluster derivation, and failure when nothing is configured;
    • a failing call's tool result, error string, span status, span attributes and span events contain no token. This is checked with an in-memory span exporter through ToolMiddleware and the k8s handler;
    • redaction of every listed flag and pattern;
    • the cache is per token;
    • builder flags are placed before --.
  • Manual check against a kind cluster, using the built binary with TOKEN_PASSTHROUGH=true, a ServiceAccount token that may list pods in one namespace, and a kubectl wrapper on PATH that records argv:
    • the allowed call succeeds and the forbidden call fails;
    • no tool result, kubectl argv, process-list sample or server log line contains the token, and no temporary kubeconfig is left behind;
    • on a forbidden call, kubectl and helm print only the API server's Forbidden message. kubectl -v=9 masks the Authorization header;
    • run against the unpatched code, the same check reported the token on argv and in a failing call's tool result, and the "forbidden" call succeeded through the base kubeconfig's client certificate.

🤖 Generated with Claude Code

https://claude.ai/code/session_016RAxvkp7wRXwXDx4Lx8STi

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant