Skip to content

feat: kbagent encrypt command for MCP tool call workflows #116

Description

@frantisekrehor

TL;DR

Expose the Keboola Encryption API as a standalone kbagent encrypt command. The main driver is MCP tool call workflows (update_config, create_config), where the ciphertext for a #-prefixed credential must exist before the tool is invoked — sync push doesn't help here. Separately, the current sync push silently falls back to pushing plaintext if the Encryption API call fails, which I'd suggest fixing in the same release.

Related: #59 asks an open question about whether config push should auto-encrypt #-prefixed values. This request is complementary — rather than making another command auto-encrypt, it proposes exposing encryption as a primitive that users (and future commands like the one in #59) can pipe through explicitly.

Background

kbagent already talks to the Encryption API internally:

  • KeboolaClient.encrypt_values() in client.py — HTTP, auth, retries, stack-URL derivation.
  • sync_service._encrypt_secrets_in_config() — walks a config dict, encrypts #-prefixed plaintext values, writes ciphertext back.

All of it fires transparently during sync push. None of it is reachable from the CLI any other way.

Motivation

Primary — MCP tool call workflows

Most config edits I make go through kbagent tool call update_config / create_config. These tools take a complete config JSON as input and forward it to the Storage API — they don't encrypt anything. So if my payload contains parameters.#api_token on a Python transformation, the ciphertext has to be ready before the tool call.

Today the only options are hand-crafted curl against the Encryption API, or a fake sync push just to extract ciphertext from the writeback. A first-class command would make the pipe workflow trivial:

CIPHER=$(echo '{"#api_token":"rotated-value"}' \
  | kbagent --json encrypt \
      --project my-client \
      --component-id keboola.python-transformation-v2 -)

kbagent tool call update_config --project my-client --input "$CIPHER"

Secondary

  • Rotating a credential in one config without a full pull/push cycle.
  • Scaffolding a new config so the first create_config payload is already correct.
  • Preparing config snippets for PR review without plaintext touching the repo.

Security issue in sync push

Separate from the command, but worth fixing together:

# src/keboola_agent_cli/services/sync_service.py ~line 1013
try:
    encrypted = client.encrypt_values(...)
    _apply_encrypted(configuration, "", encrypted)
except Exception as exc:
    logger.warning("Failed to encrypt secrets for %s: %s", component_id, exc)
return configuration

On any Encryption API failure — transient 5xx, auth issue, wrong URL — this logs a warning and returns the configuration with plaintext intact. The caller then sends it to the Storage API and writes it back to the local manifest. The failure is only visible in logs.

Proposed solution

Part A — new kbagent encrypt command

kbagent encrypt --project ALIAS --component-id ID
                [--config-id ID] [--branch-type dev|default]
                [--input JSON|@file.json|-]
                [--output-file path.json]
  • --input accepts inline JSON, @file, or - (stdin) for pipe workflows.
  • No output flag → stdout (JSON mode emits raw dict for jq consumption).
  • --output-file writes with 0600.
  • --config-id / --branch-type narrow the ciphertext scope from ComponentSecure to ConfigSecure / BranchSecure — useful for least-privilege rotation.

Upfront validation (Encryption API's own errors on these are unhelpful):

  • Keys must start with #
  • Values must be strings
  • --branch-type dev requires --config-id
  • Values already starting with KBC:: pass through unchanged — idempotent, important for pipe workflows

Help text should state explicitly that decrypt isn't supported (one-way API) — otherwise everyone asks.

Should respect the v0.17.0 permission framework via check_cli_permission(ctx, "encrypt").

Part B — fail closed in sync push

Make _encrypt_secrets_in_config() raise on Encryption API failure instead of warning and returning plaintext. Add --allow-plaintext-on-encrypt-failure as an explicit escape hatch. Breaking change, minor version bump, changelog note. Small enough to ship as its own PR.

Part C — extend encrypt_values() signature

Two optional keyword-only args, backward compatible:

def encrypt_values(
    self, project_id: int, component_id: str, data: dict[str, str],
    *, config_id: str | None = None, branch_type: str | None = None,
) -> dict[str, str]: ...

Existing sync_service callers don't change.

Alternatives considered

  • Leave encryption internal to sync push. Doesn't help the MCP tool call flow at all, which is how most config edits actually happen. Also doesn't fix the silent fallback.
  • sync push --encrypt-only. Muddies sync semantics, harder to discover, still pointless for non-sync flows.
  • Shell wrapper around curl. Every user reinvents stack-URL derivation, auth, retries, and #-key validation — all of which the CLI already has correct.
  • Auto-encrypt inside tool call update_config. Too magical; tool call should match what the MCP server actually does, not quietly rewrite payloads.

Non-goals

  • Decrypt command (API is one-way).
  • --all-projects batch mode (each project has its own encryption key).
  • Keyring / OS secret store integration.
  • YAML / TOML input — JSON-only for v1.

Impact

Change Type
New kbagent encrypt command Additive
encrypt_values() new kwargs Additive
sync push fails hard on Encryption API error Breaking — minor version bump
--allow-plaintext-on-encrypt-failure escape hatch Additive

Open questions

  1. Default scope = ComponentSecure (match current sync behavior) or require explicit --scope? I lean toward matching current behavior.
  2. Is --allow-plaintext-on-encrypt-failure too long? I think the footgun flag should look like one.
  3. Validate --config-id against an existing config, or trust the user? I lean toward trusting — Encryption API rejects invalid IDs anyway and the extra round-trip slows the pipe use case.

Happy to contribute

Suggested rollout:

  1. Part B (fail-closed sync push) — small standalone PR, ships on its own.
  2. Part C (encrypt_values() kwargs) — trivial, no behavior change.
  3. Part A (the command) — builds on C.

Wanted to align on shape before writing code, especially the breaking change in Part B.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions