fix(config): encrypt #-prefixed secrets before write (0.54.0) (#378) - #380
Conversation
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
left a comment
There was a problem hiding this comment.
Review of #380 — fix(config): encrypt #-prefixed secrets before write (0.54.0)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake 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_writecallscollect_secretsto count secrets for the guard (if not secrets: return configuration) and for the warning log, then immediately callsencrypt_secrets_in_configwhich runscollect_secretsagain internally. The dict is small and the traversal is cheap, but the double-walk is redundant. Consider passing the already-collectedsecretsdict intoencrypt_secrets_in_configor inlining the project_id guard into a lighterhas_secrets()check without building the full dict.
Verification log
gh auth status→ authenticated togithub.laiyagushi.comaspadak✓git rev-parse --abbrev-ref HEAD→claude/vigorous-moser-4cfdd2matches<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.mdPlugin 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()/ rawerror_code=string check → empty ✓ - Token discipline: new code uses
mask_token-safe paths; test fixtures use901-xxxcanonical fake token ✓ OPERATION_REGISTRYcheck:config.update,config.new,config.row-create,config.row-updateall present and categorized correctly aswrite✓AGENT_CONTEXT(commands/context.py) updated forconfig update,config new,config row-create,config row-update✓CLAUDE.md## All CLI Commandsupdated 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 include0.54.0encrypt 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.mdupdated for all four commands ✓plugins/kbagent/skills/kbagent/references/gotchas.md— new entry added with(since v0.54.0)tag ✓plugin.jsonversion updated to0.54.0✓;marketplace.jsonupdated ✓;pyproject.tomlupdated ✓;changelog.pyentry added ✓ErrorCode.ENCRYPTION_FAILEDpresent inerrors.pyas aStrEnummember ✓; test assertionsexc_info.value.error_code == "ENCRYPTION_FAILED"are valid becauseStrEnumcompares equal to string equivalents ✓kbagent serverouters (src/keboola_agent_cli/server/routers/configs.py):ConfigUpdate,ConfigCreate,ConfigCreateRow,ConfigUpdateRowdo not exposeallow_plaintext_fallback— the serve routes call the service with the defaultFalse, 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.pypre-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_idfallback viaverify_token, dry-run plaintext. NoCliRunnerCLI-layer tests [NB-1]. No E2E tests [NB-2].make check→3863 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
left a comment
There was a problem hiding this comment.
Review of #380 — fix(config): encrypt #-prefixed secrets before write (0.54.0)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake 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_writecallscollect_secretsto count secrets for the guard (if not secrets: return configuration) and for the warning log, then immediately callsencrypt_secrets_in_configwhich runscollect_secretsagain internally. The dict is small and the traversal is cheap, but the double-walk is redundant. Consider passing the already-collectedsecretsdict intoencrypt_secrets_in_configor inlining the project_id guard into a lighterhas_secrets()check without building the full dict.
Verification log
gh auth status→ authenticated togithub.laiyagushi.comaspadak✓git rev-parse --abbrev-ref HEAD→claude/vigorous-moser-4cfdd2matches<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.mdPlugin 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()/ rawerror_code=string check → empty ✓ - Token discipline: new code uses
mask_token-safe paths; test fixtures use901-xxxcanonical fake token ✓ OPERATION_REGISTRYcheck:config.update,config.new,config.row-create,config.row-updateall present and categorized correctly aswrite✓AGENT_CONTEXT(commands/context.py) updated forconfig update,config new,config row-create,config row-update✓CLAUDE.md## All CLI Commandsupdated 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 include0.54.0encrypt 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.mdupdated for all four commands ✓plugins/kbagent/skills/kbagent/references/gotchas.md— new entry added with(since v0.54.0)tag ✓plugin.jsonversion updated to0.54.0✓;marketplace.jsonupdated ✓;pyproject.tomlupdated ✓;changelog.pyentry added ✓ErrorCode.ENCRYPTION_FAILEDpresent inerrors.pyas aStrEnummember ✓; test assertionsexc_info.value.error_code == "ENCRYPTION_FAILED"are valid becauseStrEnumcompares equal to string equivalents ✓kbagent serverouters (src/keboola_agent_cli/server/routers/configs.py):ConfigUpdate,ConfigCreate,ConfigCreateRow,ConfigUpdateRowdo not exposeallow_plaintext_fallback— the serve routes call the service with the defaultFalse, 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.pypre-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_idfallback viaverify_token, dry-run plaintext. NoCliRunnerCLI-layer tests [NB-1]. No E2E tests [NB-2].make check→3863 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.
|
Addressed the non-blocking review findings in 20dca0f:
Finding #3 ( |
Summary
Fixes #378.
config create/updateandconfig row-create/row-updatewrote#-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. Onlysync pushand 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 detailread-back returned the literal plaintext; post-fix it returnsKBC::ProjectSecure::....Approach
Service-layer fix (option 2 from the issue):
ConfigService._encrypt_secrets_before_writeis called in all four write methods (create_config,update_config,create_config_row,update_config_row). Because theserveREST config routes and thekbagent toolMCP passthrough funnel through the same service, one change covers all three surfaces.ENCRYPTION_FAILEDinstead of writing plaintext.--allow-plaintext-on-encrypt-failure(named to matchsync push) downgrades that to a warning.project_idresolves from the project config with averify_tokenfallback, resolved only when secrets are actually present (secret-free writes skip the extra round-trip).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 (noencrypt_values/verify_tokenround-trip),project_idfallback viaverify_token, fail-closed whenproject_idis unresolvable, and dry-run plaintext. Full suite green —make check: 3863 passed, 8 skipped.Docs / release
make version-syncapplied).CLAUDE.mdcommand list,gotchas.md(taggedsince v0.54.0),commands-reference.md,keboola-expert.md(VERSION GATE + a small adjacent trim to stay under the agent-prompt byte budget),context.pyAGENT_CONTEXT.Notes
--set '#k=v'sets a top-level key, notparameters.#k; anddata_app_service.pyalready has its own encryption, so it is not part of the unguarded set.