Skip to content

fix(config): encrypt #-prefixed secrets before write (0.54.0) (#378) - #380

Merged
padak merged 2 commits into
mainfrom
claude/vigorous-moser-4cfdd2
Jun 3, 2026
Merged

fix(config): encrypt #-prefixed secrets before write (0.54.0) (#378)#380
padak merged 2 commits into
mainfrom
claude/vigorous-moser-4cfdd2

Conversation

@padak

@padak padak commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #378. config create/update and config row-create/row-update wrote #-prefixed secrets to Storage in plaintext. The Storage API stores config JSON verbatim and does not encrypt #-values server-side, so the client must pre-encrypt via the Encryption API. Only sync push and the variables path did this; the interactive config paths skipped it — leaving credentials readable in Storage, in every config version, and re-exposed on read. A sync action (e.g. testConnection) would even run with the live plaintext credential; the eventual job failing closed (Invalid cipher text) only masks the leak.

Verified live (dummy canary values, cleaned up afterwards) on project 10539: pre-fix config detail read-back returned the literal plaintext; post-fix it returns KBC::ProjectSecure::....

Approach

Service-layer fix (option 2 from the issue): ConfigService._encrypt_secrets_before_write is called in all four write methods (create_config, update_config, create_config_row, update_config_row). Because the serve REST config routes and the kbagent tool MCP passthrough funnel through the same service, one change covers all three surfaces.

  • Fail-closed by default — an encryption failure (or an unresolvable project scope) raises ENCRYPTION_FAILED instead of writing plaintext.
  • Escape hatch--allow-plaintext-on-encrypt-failure (named to match sync push) downgrades that to a warning.
  • project_id resolves from the project config with a verify_token fallback, resolved only when secrets are actually present (secret-free writes skip the extra round-trip).
  • Dry-run is intentionally not encrypted so the diff stays readable and deterministic (ciphertext is non-deterministic).

Tests

tests/test_config_encryption.py (11 tests): encrypt-on-write for all four paths, fail-closed on encryption error, escape hatch, secret-free skip (no encrypt_values / verify_token round-trip), project_id fallback via verify_token, fail-closed when project_id is unresolvable, and dry-run plaintext. Full suite green — make check: 3863 passed, 8 skipped.

Docs / release

  • Version bumped to 0.54.0 + changelog entry (make version-sync applied).
  • Updated silent-drift surfaces: CLAUDE.md command list, gotchas.md (tagged since v0.54.0), commands-reference.md, keboola-expert.md (VERSION GATE + a small adjacent trim to stay under the agent-prompt byte budget), context.py AGENT_CONTEXT.

Notes

  • Two minor corrections to the report (documented on the issue): --set '#k=v' sets a top-level key, not parameters.#k; and data_app_service.py already has its own encryption, so it is not part of the unguarded set.

config create/update/row-* wrote #-prefixed secrets to Storage in plaintext.
The Storage API stores config JSON verbatim and does not encrypt server-side,
so the client must pre-encrypt via the Encryption API. Only `sync push` and the
variables path did this; the interactive config paths skipped it, leaving
credentials readable in Storage, in every config version, and re-exposed on
read (verified live on projects 4214 and 10539).

The fix is service-layer (ConfigService._encrypt_secrets_before_write), so it
also covers the `serve` REST config routes and the `kbagent tool` MCP
passthrough that funnel through the same service. Fail-closed by default
(ENCRYPTION_FAILED on encryption error or unresolvable project scope); the new
--allow-plaintext-on-encrypt-failure flag (matching `sync push`) overrides.
project_id resolves from config with a verify_token fallback, only when secrets
are present. Dry-run is not encrypted so the diff stays readable.

Tests: tests/test_config_encryption.py (encrypt-on-write for all four paths,
fail-closed, escape hatch, secret-free skip, project_id fallback, dry-run).
Docs: CLAUDE.md, gotchas.md, commands-reference.md, keboola-expert.md, context.py.

@padak padak left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of #380 — fix(config): encrypt #-prefixed secrets before write (0.54.0)

Generated by kbagent-pr-reviewer subagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed via make check, not duplicated here.

Summary

This PR plugs a real security hole: config update, config new --push, config row-create, and config row-update previously wrote #-prefixed secrets to the Storage API in plaintext. The fix is clean, service-layer, fail-closed by default, and the implementation re-uses the existing _encryption.py helpers that sync push and the variables path already used. The PR author has walked every mandatory silent-drift surface (AGENT_CONTEXT, CLAUDE.md, gotchas.md, commands-reference.md, keboola-expert.md §3). There are no blocking findings. Two non-blocking findings (missing CLI-layer tests, and the file size ceiling already exceeded) and one nit.

Verdict: APPROVE

Verdict

  • Verdict: APPROVE
  • Blocking findings: 0
  • Non-blocking findings: 3
  • Nits: 1

Blocking findings

(none)

Non-blocking findings

[NB-1] tests/test_config_encryption.py — no CLI-layer (CliRunner) tests for the new --allow-plaintext-on-encrypt-failure flag

The new test file (257 lines, 11 test methods) exercises the service layer (ConfigService) directly via mock injected clients. That is the right level for business-logic coverage. However, there are no typer.testing.CliRunner tests verifying that the --allow-plaintext-on-encrypt-failure flag is wired correctly from the Typer argument parser to the service call in all four commands (config update, config new, config row-create, config row-update). This is a CLI contract test — it catches the kind of bug where allow_plaintext is added as a Typer option but the call site forgets to pass it through (the argument silently defaults to False in the service, so no error, but the flag is a no-op).

Per CONTRIBUTING.md > "Tests (mandatory!)": every command must have CLI-layer tests. Four commands changed; four CliRunner tests are missing.

Fix: add a test class (e.g., TestAllowPlaintextFlagWiring) to an existing CLI test file (tests/test_cli.py or tests/test_config_update.py) with one test per command that passes --allow-plaintext-on-encrypt-failure and asserts mock_service.update_config.call_args.kwargs["allow_plaintext_fallback"] == True.

[NB-2] tests/test_e2e.py — no E2E test for the new encryption behavior

CONTRIBUTING.md > "Tests (mandatory!)" requires E2E coverage in tests/test_e2e.py for every new CLI behavior. This PR changes behavior on four command paths (config update, config new --push, config row-create, config row-update) such that a real #-prefixed secret is now encrypted before reaching Storage. There is no E2E test confirming that after a config update --configuration '{"#password": "secret"}' the read-back via config detail returns a KBC::ProjectSecure::... value rather than the plaintext.

Per CONTRIBUTING.md > "E2E tests" the rule is: "every CLI command must have E2E coverage"; behavior changes on existing commands are held to the same bar. The author confirmed live verification on projects 4214 and 10539 in the PR description, which substitutes partially — but the verification is not codified and will regress undetected if a future refactor changes the call order.

Fix: add a test case in tests/test_e2e.py (e.g., under TestE2EConfigUpdateNormalization or a new TestE2EConfigEncryption class) that runs config update with a #-prefixed key, then config detail, and asserts the read-back value starts with KBC::.

[NB-3] src/keboola_agent_cli/services/config_service.py:2099 — file exceeds the hard LOC ceiling, this PR pushes it further

CONTRIBUTING.md > "File-size budgets" states a hard ceiling of 1500 LOC for service files. config_service.py was already at 1975 lines on main (well past both the 1000-line soft ceiling and the 1500-line hard ceiling). This PR adds 124 more lines, bringing it to 2099. Per the rule: "When a file crosses the hard ceiling, splitting is required before merging more functionality into it."

This finding is non-blocking rather than blocking for two reasons: (a) the file was already over the hard ceiling before this PR (the violation pre-dates this change), and (b) the security fix in this PR is genuinely load-bearing and deferring it to wait for a split would extend the plaintext-leak window.

Fix: track as a follow-up issue. The natural split is by concern: config_service.py already mixes config CRUD, config metadata, config search, config rows, config variables, sync-directory rename, and now encryption. Extracting at least the encryption helpers (which already live in _encryption.py) and the rename logic to a sibling _rename.py or splitting into config_service_crud.py + config_service_sync.py would bring the main file back under ceiling.

Nits

  • [NIT-1] src/keboola_agent_cli/services/config_service.py:615-617_encrypt_secrets_before_write calls collect_secrets to count secrets for the guard (if not secrets: return configuration) and for the warning log, then immediately calls encrypt_secrets_in_config which runs collect_secrets again internally. The dict is small and the traversal is cheap, but the double-walk is redundant. Consider passing the already-collected secrets dict into encrypt_secrets_in_config or inlining the project_id guard into a lighter has_secrets() check without building the full dict.

Verification log

  • gh auth status → authenticated to github.com as padak
  • git rev-parse --abbrev-ref HEADclaude/vigorous-moser-4cfdd2 matches <branch>
  • gh pr view 380 --json title,body,files,additions,deletions,baseRefName,headRefName,labels,state → state=OPEN, 13 files, +475/-38, fix(config): prefix ✓
  • Read CONTRIBUTING.md Plugin synchronization map ✓
  • Read CLAUDE.md §17 silent-drift surfaces ✓
  • Read plugins/kbagent/agents/keboola-expert.md §1 Rule 6, §2, §3 ✓
  • gh pr diff 380 → 839 diff lines fetched ✓
  • Layer violation check (typer/click in services, httpx in commands, formatter in clients) → empty ✓ (no layer violations)
  • Magic numbers / bare except: / print() / raw error_code= string check → empty ✓
  • Token discipline: new code uses mask_token-safe paths; test fixtures use 901-xxx canonical fake token ✓
  • OPERATION_REGISTRY check: config.update, config.new, config.row-create, config.row-update all present and categorized correctly as write
  • AGENT_CONTEXT (commands/context.py) updated for config update, config new, config row-create, config row-update
  • CLAUDE.md ## All CLI Commands updated for all four write paths with [--allow-plaintext-on-encrypt-failure]
  • plugins/kbagent/agents/keboola-expert.md §3 inline gotcha added (0.54.0+, #378) with VERSION GATE note ✓; §1 Rule 6 VERSION GATE list does not include 0.54.0 encrypt behavior (this is correct — Rule 6 gates on commands that don't exist on older versions, not behavior changes; the encrypt behavior is not a new command)
  • plugins/kbagent/skills/kbagent/references/commands-reference.md updated for all four commands ✓
  • plugins/kbagent/skills/kbagent/references/gotchas.md — new entry added with (since v0.54.0) tag ✓
  • plugin.json version updated to 0.54.0 ✓; marketplace.json updated ✓; pyproject.toml updated ✓; changelog.py entry added ✓
  • ErrorCode.ENCRYPTION_FAILED present in errors.py as a StrEnum member ✓; test assertions exc_info.value.error_code == "ENCRYPTION_FAILED" are valid because StrEnum compares equal to string equivalents ✓
  • kbagent serve routers (src/keboola_agent_cli/server/routers/configs.py): ConfigUpdate, ConfigCreate, ConfigCreateRow, ConfigUpdateRow do not expose allow_plaintext_fallback — the serve routes call the service with the default False, so encryption is always enforced for REST callers (consistent with fail-closed design; REST callers that need the escape hatch must use the CLI). Informational only — not a finding.
  • config_service.py pre-PR line count (main): 1975; post-PR: 2099; hard ceiling: 1500 — already exceeded before this PR [NB-3]
  • tests/test_config_encryption.py: 11 service-layer tests covering all four write paths, fail-closed, escape hatch, secret-free skip, project_id fallback via verify_token, dry-run plaintext. No CliRunner CLI-layer tests [NB-1]. No E2E tests [NB-2].
  • make check3863 passed, 8 skipped ✓ (exit 0)
  • Behavior verification: author reported live verification on projects 4214 and 10539; cannot independently reproduce without E2E credentials in this context. The service-layer tests provide adequate functional confidence.

Open questions for the author

(none)

@padak padak left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of #380 — fix(config): encrypt #-prefixed secrets before write (0.54.0)

Generated by kbagent-pr-reviewer subagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed via make check, not duplicated here.

Summary

This PR plugs a real security hole: config update, config new --push, config row-create, and config row-update previously wrote #-prefixed secrets to the Storage API in plaintext. The fix is clean, service-layer, fail-closed by default, and the implementation re-uses the existing _encryption.py helpers that sync push and the variables path already used. The PR author has walked every mandatory silent-drift surface (AGENT_CONTEXT, CLAUDE.md, gotchas.md, commands-reference.md, keboola-expert.md §3). There are no blocking findings. Two non-blocking findings (missing CLI-layer tests, and the file size ceiling already exceeded) and one nit.

Verdict: APPROVE

Verdict

  • Verdict: APPROVE
  • Blocking findings: 0
  • Non-blocking findings: 3
  • Nits: 1

Blocking findings

(none)

Non-blocking findings

[NB-1] tests/test_config_encryption.py — no CLI-layer (CliRunner) tests for the new --allow-plaintext-on-encrypt-failure flag

The new test file (257 lines, 11 test methods) exercises the service layer (ConfigService) directly via mock injected clients. That is the right level for business-logic coverage. However, there are no typer.testing.CliRunner tests verifying that the --allow-plaintext-on-encrypt-failure flag is wired correctly from the Typer argument parser to the service call in all four commands (config update, config new, config row-create, config row-update). This is a CLI contract test — it catches the kind of bug where allow_plaintext is added as a Typer option but the call site forgets to pass it through (the argument silently defaults to False in the service, so no error, but the flag is a no-op).

Per CONTRIBUTING.md > "Tests (mandatory!)": every command must have CLI-layer tests. Four commands changed; four CliRunner tests are missing.

Fix: add a test class (e.g., TestAllowPlaintextFlagWiring) to an existing CLI test file (tests/test_cli.py or tests/test_config_update.py) with one test per command that passes --allow-plaintext-on-encrypt-failure and asserts mock_service.update_config.call_args.kwargs["allow_plaintext_fallback"] == True.

[NB-2] tests/test_e2e.py — no E2E test for the new encryption behavior

CONTRIBUTING.md > "Tests (mandatory!)" requires E2E coverage in tests/test_e2e.py for every new CLI behavior. This PR changes behavior on four command paths (config update, config new --push, config row-create, config row-update) such that a real #-prefixed secret is now encrypted before reaching Storage. There is no E2E test confirming that after a config update --configuration '{"#password": "secret"}' the read-back via config detail returns a KBC::ProjectSecure::... value rather than the plaintext.

Per CONTRIBUTING.md > "E2E tests" the rule is: "every CLI command must have E2E coverage"; behavior changes on existing commands are held to the same bar. The author confirmed live verification on projects 4214 and 10539 in the PR description, which substitutes partially — but the verification is not codified and will regress undetected if a future refactor changes the call order.

Fix: add a test case in tests/test_e2e.py (e.g., under TestE2EConfigUpdateNormalization or a new TestE2EConfigEncryption class) that runs config update with a #-prefixed key, then config detail, and asserts the read-back value starts with KBC::.

[NB-3] src/keboola_agent_cli/services/config_service.py:2099 — file exceeds the hard LOC ceiling, this PR pushes it further

CONTRIBUTING.md > "File-size budgets" states a hard ceiling of 1500 LOC for service files. config_service.py was already at 1975 lines on main (well past both the 1000-line soft ceiling and the 1500-line hard ceiling). This PR adds 124 more lines, bringing it to 2099. Per the rule: "When a file crosses the hard ceiling, splitting is required before merging more functionality into it."

This finding is non-blocking rather than blocking for two reasons: (a) the file was already over the hard ceiling before this PR (the violation pre-dates this change), and (b) the security fix in this PR is genuinely load-bearing and deferring it to wait for a split would extend the plaintext-leak window.

Fix: track as a follow-up issue. The natural split is by concern: config_service.py already mixes config CRUD, config metadata, config search, config rows, config variables, sync-directory rename, and now encryption. Extracting at least the encryption helpers (which already live in _encryption.py) and the rename logic to a sibling _rename.py or splitting into config_service_crud.py + config_service_sync.py would bring the main file back under ceiling.

Nits

  • [NIT-1] src/keboola_agent_cli/services/config_service.py:615-617_encrypt_secrets_before_write calls collect_secrets to count secrets for the guard (if not secrets: return configuration) and for the warning log, then immediately calls encrypt_secrets_in_config which runs collect_secrets again internally. The dict is small and the traversal is cheap, but the double-walk is redundant. Consider passing the already-collected secrets dict into encrypt_secrets_in_config or inlining the project_id guard into a lighter has_secrets() check without building the full dict.

Verification log

  • gh auth status → authenticated to github.com as padak
  • git rev-parse --abbrev-ref HEADclaude/vigorous-moser-4cfdd2 matches <branch>
  • gh pr view 380 --json title,body,files,additions,deletions,baseRefName,headRefName,labels,state → state=OPEN, 13 files, +475/-38, fix(config): prefix ✓
  • Read CONTRIBUTING.md Plugin synchronization map ✓
  • Read CLAUDE.md §17 silent-drift surfaces ✓
  • Read plugins/kbagent/agents/keboola-expert.md §1 Rule 6, §2, §3 ✓
  • gh pr diff 380 → 839 diff lines fetched ✓
  • Layer violation check (typer/click in services, httpx in commands, formatter in clients) → empty ✓ (no layer violations)
  • Magic numbers / bare except: / print() / raw error_code= string check → empty ✓
  • Token discipline: new code uses mask_token-safe paths; test fixtures use 901-xxx canonical fake token ✓
  • OPERATION_REGISTRY check: config.update, config.new, config.row-create, config.row-update all present and categorized correctly as write
  • AGENT_CONTEXT (commands/context.py) updated for config update, config new, config row-create, config row-update
  • CLAUDE.md ## All CLI Commands updated for all four write paths with [--allow-plaintext-on-encrypt-failure]
  • plugins/kbagent/agents/keboola-expert.md §3 inline gotcha added (0.54.0+, #378) with VERSION GATE note ✓; §1 Rule 6 VERSION GATE list does not include 0.54.0 encrypt behavior (this is correct — Rule 6 gates on commands that don't exist on older versions, not behavior changes; the encrypt behavior is not a new command)
  • plugins/kbagent/skills/kbagent/references/commands-reference.md updated for all four commands ✓
  • plugins/kbagent/skills/kbagent/references/gotchas.md — new entry added with (since v0.54.0) tag ✓
  • plugin.json version updated to 0.54.0 ✓; marketplace.json updated ✓; pyproject.toml updated ✓; changelog.py entry added ✓
  • ErrorCode.ENCRYPTION_FAILED present in errors.py as a StrEnum member ✓; test assertions exc_info.value.error_code == "ENCRYPTION_FAILED" are valid because StrEnum compares equal to string equivalents ✓
  • kbagent serve routers (src/keboola_agent_cli/server/routers/configs.py): ConfigUpdate, ConfigCreate, ConfigCreateRow, ConfigUpdateRow do not expose allow_plaintext_fallback — the serve routes call the service with the default False, so encryption is always enforced for REST callers (consistent with fail-closed design; REST callers that need the escape hatch must use the CLI). Informational only — not a finding.
  • config_service.py pre-PR line count (main): 1975; post-PR: 2099; hard ceiling: 1500 — already exceeded before this PR [NB-3]
  • tests/test_config_encryption.py: 11 service-layer tests covering all four write paths, fail-closed, escape hatch, secret-free skip, project_id fallback via verify_token, dry-run plaintext. No CliRunner CLI-layer tests [NB-1]. No E2E tests [NB-2].
  • make check3863 passed, 8 skipped ✓ (exit 0)
  • Behavior verification: author reported live verification on projects 4214 and 10539; cannot independently reproduce without E2E credentials in this context. The service-layer tests provide adequate functional confidence.

Open questions for the author

(none)

Addresses non-blocking review findings on PR #380:

- CLI-layer (CliRunner) tests asserting --allow-plaintext-on-encrypt-failure
  reaches the service as allow_plaintext_fallback for config update / new --push
  / row-create / row-update, plus the default-False case.
- E2E regression test (TestE2EConfigSecretEncryption): config new --push and
  config update read back as KBC:: (not plaintext) against a live project, and
  config update --dry-run keeps plaintext in the diff. Probe config deleted in
  teardown.
@padak

padak commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Addressed the non-blocking review findings in 20dca0f:

  • Phase 1: Project Skeleton #1 CLI-layer tests — added CliRunner tests asserting --allow-plaintext-on-encrypt-failure reaches the service as allow_plaintext_fallback for all four commands (config update / new --push / row-create / row-update), plus the default-False case.
  • Phase 2: Project management (client, config store, service, CLI) #2 E2E coverage — added TestE2EConfigSecretEncryption: config new --push and config update read back as KBC:: (not plaintext) against a live project, and config update --dry-run keeps plaintext in the diff. Probe config is deleted in teardown.

make check: 3868 passed, 8 skipped.

Finding #3 (config_service.py over the 1500-line ceiling) is a pre-existing violation and out of scope for this security fix — better handled as a standalone refactor follow-up.

@padak
padak merged commit c98f645 into main Jun 3, 2026
2 checks passed
@padak
padak deleted the claude/vigorous-moser-4cfdd2 branch June 3, 2026 21:31
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.

config create/update store #-prefixed secrets as plaintext (Encryption API only applied on sync/variables paths)

1 participant