From fa39f137b9290cb7d11e28b03c0d131ab11bdb68 Mon Sep 17 00:00:00 2001 From: ottomansky Date: Sun, 3 May 2026 14:32:48 +0200 Subject: [PATCH] feat(0.28.0): manage-token default-deny -- env var ignored without --allow-env-manage-token, TTY prompt is the default BREAKING: KBC_MANAGE_API_TOKEN is no longer auto-resolved on the three commands that consume it (org setup, project refresh, data-app password). Default behaviour: emit a one-shot stderr warning, ignore the env, fall through to a TTY hidden-input prompt; exit 2 with no TTY. To opt in (CI/CD), pass the new top-level flag --allow-env-manage-token. The change closes the AI-exfiltration risk where any subprocess running as the same user (including the AI agent itself) inherits the manage token via env. Storage tokens (KBC_TOKEN) are unaffected. Supersedes the per-stack design discussed in #238 per Padak's review: "Subprocess kbagent invocations continue to require explicit --allow-env-manage-token or TTY prompt." Phase 2 (REPL-process-memory caching for human admin work) and Phase 3 (hardware-key) are roadmap. Implementation: - Resolver: src/keboola_agent_cli/commands/_helpers.py:27 -- resolve_manage_token(*, allow_env: bool = False) with default-deny behaviour, one-shot stderr warning, actionable exit-2 message. - Top-level flag: src/keboola_agent_cli/cli.py -- new --allow-env-manage-token Option mirroring --deny-writes shape; plumbed via ctx.obj["allow_env_manage_token"]. - Call sites (3): commands/data_app.py:594, commands/org.py:223, commands/project.py:423 -- all pass allow_env=ctx.obj[...] into the resolver. - Bulk-prompt-once preserved by construction: resolver lives at command entry, before any per-project loop. Tests (12 new, 3 existing updated): - tests/test_helpers.py::TestResolveManageToken (7 tests): allow_env-True/False x env-set/unset x TTY/non-TTY combinations + token-leak regression pin + flag-is-permission-not-promise pin. - tests/test_manage_token_cli.py::TestAllowEnvManageTokenFlag (4 tests): project-refresh / org-setup / data-app-password through CliRunner with services mocked, default-deny vs allow-env paths. - tests/test_manage_token_bulk.py::TestBulkPromptOnce (1 test): pins that "project refresh --all" resolves the token exactly once at command entry, never per-project. Patches resolve_manage_token itself for unambiguous count-based assertion. - tests/test_cli.py and tests/test_data_app_cli.py: 3 existing tests updated to pass --allow-env-manage-token (they were exercising the legacy env-default contract; now they pin the opt-in path). Sync map walk: - commands/context.py AGENT_CONTEXT (org-setup example, --project-ids prose, data-app password prose, env-var help block) - CLAUDE.md convention #12 + global-flag list - plugins/kbagent/agents/keboola-expert.md Rule 6 VERSION GATE, tool-selection-matrix row, new inline-gotcha block - plugins/kbagent/skills/kbagent/references/gotchas.md new entry tagged (since v0.28.0) - plugins/kbagent/skills/kbagent/references/commands-reference.md three command rows + env-var table - pyproject.toml 0.27.0 -> 0.28.0; make version-sync propagated to plugin.json + marketplace.json - changelog.py: new 0.28.0 block (BREAKING + Security + new flag + Tests + Docs) make check: 2444 passed, 5 skipped, 64 deselected, 0 failed. --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 4 +- README.md | 12 +- docs/TUTORIAL.md | 39 +++- docs/e2e-scenarios.md | 4 +- plugins/kbagent/.claude-plugin/plugin.json | 2 +- plugins/kbagent/agents/keboola-expert.md | 21 +- plugins/kbagent/skills/kbagent/SKILL.md | 6 +- .../kbagent/references/commands-reference.md | 11 +- .../kbagent/references/data-app-workflow.md | 4 +- .../skills/kbagent/references/gotchas.md | 35 +++- pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 7 + src/keboola_agent_cli/cli.py | 9 + src/keboola_agent_cli/commands/_helpers.py | 38 +++- src/keboola_agent_cli/commands/context.py | 23 ++- src/keboola_agent_cli/commands/data_app.py | 8 +- src/keboola_agent_cli/commands/org.py | 8 +- src/keboola_agent_cli/commands/project.py | 6 +- src/keboola_agent_cli/commands/repl.py | 9 +- .../hints/definitions/data_app.py | 7 +- .../hints/definitions/org.py | 4 +- .../services/data_app_service.py | 3 +- tests/test_cli.py | 13 +- tests/test_data_app_cli.py | 1 + tests/test_helpers.py | 128 ++++++++++++ tests/test_manage_token_bulk.py | 87 ++++++++ tests/test_manage_token_cli.py | 188 ++++++++++++++++++ uv.lock | 2 +- 29 files changed, 617 insertions(+), 66 deletions(-) create mode 100644 tests/test_manage_token_bulk.py create mode 100644 tests/test_manage_token_cli.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 703a3762..36d079a5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.27.0", + "version": "0.28.0", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/CLAUDE.md b/CLAUDE.md index a69ab61e..76dc9efc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,7 +195,7 @@ All three inherit from `BaseHttpClient` (`http_base.py`) which provides shared r 11. **Error accumulation**: multi-project operations collect per-project errors without stopping. One project failing doesn't block others (see `lineage_service.py`, `org_service.py`). -12. **Manage token security**: never persisted, never passed as CLI argument, never logged. Only via `KBC_MANAGE_API_TOKEN` env var or interactive hidden prompt. +12. **Manage token security**: never persisted, never passed as CLI argument, never logged. Default-deny since 0.28.0: only via interactive hidden prompt; the `KBC_MANAGE_API_TOKEN` env var is **ignored** unless the top-level `--allow-env-manage-token` flag is passed. Default-deny closes the AI-exfiltration risk where any subprocess (including the AI agent itself) inherits the manage token via env. CI/CD callers must opt in explicitly. 13. **Idempotency**: `org setup` skips already-registered projects by matching `project_id`. Safe to re-run. @@ -251,7 +251,7 @@ plugins/kbagent/ > "Plugin synchronization map" for the full list. ``` -# Global options: --json, --verbose, --no-color, --config-dir, --hint client|service, --deny-writes, --deny-destructive +# Global options: --json, --verbose, --no-color, --config-dir, --hint client|service, --deny-writes, --deny-destructive, --allow-env-manage-token kbagent project add --project NAME --url URL --token TOKEN kbagent project list diff --git a/README.md b/README.md index 8087405c..1a8bdb34 100644 --- a/README.md +++ b/README.md @@ -102,14 +102,22 @@ kbagent project add --project prod --url https://connection.keboola.com --token **Many projects by ID** — you have a Manage API or Personal Access Token + the project IDs: ```bash +# Interactive: kbagent will prompt for the Manage API token (default since v0.28.0). +kbagent org setup --project-ids 901,9621,10539 --url https://connection.keboola.com --yes + +# CI / non-interactive: opt in to env-var resolution with --allow-env-manage-token. KBC_MANAGE_API_TOKEN=your-manage-or-personal-token \ - kbagent org setup --project-ids 901,9621,10539 --url https://connection.keboola.com --yes + kbagent --allow-env-manage-token org setup --project-ids 901,9621,10539 --url https://connection.keboola.com --yes ``` **Whole organization** — you are org admin: ```bash +# Interactive (default since v0.28.0): kbagent prompts for the Manage API token. +kbagent org setup --org-id 123 --url https://connection.keboola.com --yes + +# CI / non-interactive: KBC_MANAGE_API_TOKEN=your-org-admin-manage-token \ - kbagent org setup --org-id 123 --url https://connection.keboola.com --yes + kbagent --allow-env-manage-token org setup --org-id 123 --url https://connection.keboola.com --yes ``` Run `kbagent doctor` to verify setup (token validity, CLI version, MCP server, Claude Code plugin install). diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 79ccce80..f17d8272 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -102,12 +102,19 @@ use `org setup --project-ids`. kbagent creates a Storage API token in each listed project and registers them all locally, in parallel. ```bash -export KBC_MANAGE_API_TOKEN=YOUR_MANAGE_OR_PAT_TOKEN - +# Interactive (default since v0.28.0): kbagent prompts for the Manage API +# token on stdin. No env var, no shell history. kbagent org setup \ --project-ids 901,9621,10539 \ --url https://connection.keboola.com \ --dry-run # always dry-run first + +# CI / non-interactive: opt in to env-var resolution. +export KBC_MANAGE_API_TOKEN=YOUR_MANAGE_OR_PAT_TOKEN +kbagent --allow-env-manage-token org setup \ + --project-ids 901,9621,10539 \ + --url https://connection.keboola.com \ + --dry-run ``` The dry-run prints what would happen (create token + register alias @@ -132,10 +139,14 @@ Flags worth knowing: The command is **idempotent**: running it again skips projects that are already registered. Safe to re-run after adding new project IDs. -**Security note**: `KBC_MANAGE_API_TOKEN` is read only from env or -from an interactive hidden prompt. kbagent never accepts it as a CLI -argument (`--token xxx`) -- that would leak into shell history and -process listings. +**Security note (since v0.28.0)**: `KBC_MANAGE_API_TOKEN` is **ignored +by default** -- the env var is read only when the top-level +`--allow-env-manage-token` flag is passed. Without the flag, kbagent +prompts on stdin (hidden input). kbagent never accepts the token as a +CLI argument (`--token xxx`) -- that would leak into shell history and +process listings. The default-deny closes an AI-exfiltration risk where +any subprocess running as the same user (including the AI agent itself) +inherits env vars. --- @@ -145,12 +156,18 @@ If you are an org admin with a Manage API token, register **every** project in an organization in one shot: ```bash -export KBC_MANAGE_API_TOKEN=YOUR_ORG_ADMIN_MANAGE_TOKEN - +# Interactive (default since v0.28.0): kbagent org setup \ --org-id 123 \ --url https://connection.keboola.com \ --dry-run + +# CI / non-interactive: +export KBC_MANAGE_API_TOKEN=YOUR_ORG_ADMIN_MANAGE_TOKEN +kbagent --allow-env-manage-token org setup \ + --org-id 123 \ + --url https://connection.keboola.com \ + --dry-run ``` The dry-run reports how many projects will be registered and the @@ -859,7 +876,8 @@ kbagent --json data-app create \ deploy. To retrieve it: ```bash -# Requires KBC_MANAGE_API_TOKEN in env (org-scoped Manage API token). +# Manage API token: interactive prompt by default (since v0.28.0). For CI, +# add `--allow-env-manage-token` and set KBC_MANAGE_API_TOKEN in env. kbagent --json data-app password \ --project prod --app-id 12345678 \ | jq -r '.data.password' @@ -949,7 +967,8 @@ shapes, and the `--hint client|service` code-generation contract, see | `kbagent: command not found` after `uv tool install` | Ensure `~/.local/bin` (or uv's tool dir) is on your PATH. `uv tool update-shell` can help. | | `kbagent doctor` reports `warn` for plugin | Run the two `/plugin` commands shown in the warning, from inside Claude Code. | | Plugin version != CLI version | In Claude Code: `/plugin update kbagent`. | -| `org setup` fails with `401 Unauthorized` | Your `KBC_MANAGE_API_TOKEN` is wrong for this stack or role. Manage tokens are stack-specific and require the right scope. | +| `org setup` exits 2 with `Warning: KBC_MANAGE_API_TOKEN found in environment but ignored` | Default-deny since v0.28.0 -- pass `--allow-env-manage-token` (top-level flag) to opt in to env resolution, or run interactively to use the prompt. | +| `org setup` fails with `401 Unauthorized` | Your manage token is wrong for this stack or role. Manage tokens are stack-specific and require the right scope. | | `org setup --org-id` fails with `403` | You are not an org admin. Use `--project-ids` with a Personal Access Token instead (works for any project member). | | Changes from `kbagent config update` do not show in UI | You are on a dev branch. Run `kbagent branch list` and `kbagent project current` to verify the active branch; changes in a dev branch merge to production only via the UI merge step (`kbagent branch merge` returns the merge URL). | | The specialist subagent does not spawn when I type `/keboola X` | Plugin is not installed or is outdated. Run `kbagent doctor` and follow the reported commands. | diff --git a/docs/e2e-scenarios.md b/docs/e2e-scenarios.md index d2622eb8..e78cd87d 100644 --- a/docs/e2e-scenarios.md +++ b/docs/e2e-scenarios.md @@ -211,8 +211,8 @@ Skipped if `keboola-mcp-server` is not installed. | Command | Reason | |---------|--------| -| `project refresh` | Requires Manage API token (`KBC_MANAGE_API_TOKEN`) | -| `org setup` | Requires Manage API token + destructive (registers projects in org) | +| `project refresh` | Requires Manage API token (interactive prompt by default since v0.28.0; `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for non-interactive runners) | +| `org setup` | Requires Manage API token (same prompt-or-flag as above) + destructive (registers projects in org) | | `sharing share/unshare` | Requires org-level permissions or second project | | `sharing link/unlink` | Requires shared bucket from another project | | `permissions set/reset` | Interactive random-code confirmation blocks automated testing | diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index cf6d1764..2d06ad7c 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.27.0", + "version": "0.28.0", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index c6cc4fa7..649d7552 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -64,7 +64,10 @@ a critical failure. needed for the current task (e.g. `flow update` needs 0.22.0+, `schedule find` needs 0.23.0+, `config set-default-bucket` needs 0.26.0+, `data-app create / deploy / start / stop / delete / password` - need 0.27.0+, `storage retype` is a future composite), you MUST refuse + need 0.27.0+, env-var manage-token auth for `org setup` / + `project refresh` / `data-app password` needs 0.28.0+ with + `--allow-env-manage-token` (the env var is default-deny on 0.28.0+), + `storage retype` is a future composite), you MUST refuse the task and return a handoff message to the parent: `"Cannot proceed safely on kbagent . Missing: . Ask user to run kbagent update, then re-invoke me."` Do not attempt the task with @@ -102,7 +105,7 @@ a critical failure. | Roll out a new code or config version on a data app | `kbagent data-app deploy --project P --app-id N --wait` (0.27.0+) -- always sends the §9 trio | `kbagent --hint client data-app deploy ...` to inspect the generated `patch_app(desired_state=, config_version=, restart_if_running=True)` call | `tool call update_config` then `tool call run_component` (data apps are not jobs -- the queue runner does not deploy them) | | Wake an auto-suspended data app | `kbagent data-app start --project P --app-id N` (0.27.0+) -- does NOT bump configVersion | hitting the app's URL (auto-restart triggers a 30-60s cold boot) | `kbagent data-app deploy` (overkill -- bumps the deployed configVersion unnecessarily) | | Pause a running data app | `kbagent data-app stop --project P --app-id N` (0.27.0+) | -- | `kbagent data-app delete` (irreversible; cascades to Storage config) | -| Read the simpleAuth password for a password-gated app | `kbagent data-app password --project P --app-id N` (0.27.0+) -- requires `KBC_MANAGE_API_TOKEN` | -- | trying to "rotate" the password (not supported by the API; delete + recreate to mint a new one) | +| Read the simpleAuth password for a password-gated app | `kbagent data-app password --project P --app-id N` (0.27.0+) -- needs Manage API token (interactive prompt by default; `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.28.0+) | -- | trying to "rotate" the password (not supported by the API; delete + recreate to mint a new one) | | Tear down a data app | `kbagent data-app delete --project P --app-id N` (0.27.0+) -- cascades to Storage config; URL retired | -- | manually `tool call delete_config keboola.data-apps` while leaving the deployment record orphaned | If the table does not cover the user's task, **ask clarifying @@ -253,6 +256,20 @@ success, not a failure. `bigquery_path` will be dataset-qualified only -- if the user needs a fully-qualified GCP path, ask them for the project name explicitly. +- **Manage-token env-var is opt-in (since 0.28.0)**. + `KBC_MANAGE_API_TOKEN` is no longer auto-resolved for `org setup`, + `project refresh`, or `data-app password`. Default behaviour: emit a + warning, ignore the env var, fall through to a TTY hidden-input prompt; + exit 2 with no TTY. To opt in (CI/CD), pass the top-level flag: + `kbagent --allow-env-manage-token --json org setup ...`. The flag is + session-only -- not persisted, no env-var equivalent. Default-deny + closes the AI-exfiltration risk where a subprocess running as the same + user (including the agent itself) inherits the manage token. If you + see `Warning: KBC_MANAGE_API_TOKEN found in environment but ignored` + in stderr, that is the expected default; tell the user to add + `--allow-env-manage-token` to their invocation, never strip the + warning by suppressing stderr. + --- ## 4. WORKFLOWS (reference playbooks) diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 39bbda12..5e12434d 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -271,8 +271,10 @@ Then add projects: kbagent --json project add --project prod --url https://connection.keboola.com --token YOUR_TOKEN # Or bulk-onboard from organization (org admin) -KBC_MANAGE_API_TOKEN=xxx kbagent --json org setup --org-id 123 --url https://connection.keboola.com --yes +# Manage token: interactive prompt by default; for CI add --allow-env-manage-token +# alongside KBC_MANAGE_API_TOKEN (required since v0.28.0). +KBC_MANAGE_API_TOKEN=xxx kbagent --allow-env-manage-token --json org setup --org-id 123 --url https://connection.keboola.com --yes # Or onboard specific projects (any project member, uses Personal Access Token) -KBC_MANAGE_API_TOKEN=xxx kbagent --json org setup --project-ids 901,9621,10539 --url https://connection.keboola.com --yes +KBC_MANAGE_API_TOKEN=xxx kbagent --allow-env-manage-token --json org setup --project-ids 901,9621,10539 --url https://connection.keboola.com --yes ``` diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 2976b66c..e31ae7a4 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -24,11 +24,12 @@ All commands support `--json` for structured output. Multi-project flags (`--pro ## Permission flags (top-level, session-only) - `--deny-writes` -- block all write/destructive/admin operations for this single invocation. Merges with any persisted permission policy; never written to config.json. Exit code 6 (PERMISSION_DENIED) on blocked operations - `--deny-destructive` -- block only destructive operations (delete-table, delete-bucket, terminate-job, etc.) for this invocation. Pure-write ops like create-table stay allowed. Use this when you want to keep build-up capabilities but lock out tear-downs -- Both flags compose: `kbagent --deny-writes --deny-destructive ...` is the safest read-only run +- `--allow-env-manage-token` -- opt in to reading `KBC_MANAGE_API_TOKEN` from env (default-deny since v0.28.0). Without it the env var is ignored and an interactive hidden prompt is required for `org setup` / `project refresh` / `data-app password`. Closes the AI-exfiltration risk where any subprocess inherits the manage token via env. Session-only; not persisted; no env-var equivalent (intentional, would re-create the hole). REPL forwards this flag to nested invocations the same way it forwards the deny-* flags +- All three flags compose: `kbagent --deny-writes --deny-destructive --allow-env-manage-token ...` is the safest CI-friendly invocation ## Organization -- `org setup --org-id ID --url URL [--dry-run] [--yes]` -- bulk-onboard all projects from an org (org admin, needs `KBC_MANAGE_API_TOKEN`) -- `org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes]` -- onboard specific projects by ID (any project member, works with Personal Access Token via `KBC_MANAGE_API_TOKEN`) +- `org setup --org-id ID --url URL [--dry-run] [--yes]` -- bulk-onboard all projects from an org (org admin; manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.28.0+) +- `org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes]` -- onboard specific projects by ID (any project member; manage token / Personal Access Token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.28.0+) ## Component Discovery - `component list [--project NAME] [--type TYPE] [--query "text"]` -- list/search components (AI-powered with `--query`) @@ -125,7 +126,7 @@ Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, - `data-app start --project NAME --app-id ID [--wait] [--timeout SECONDS]` -- wake an auto-suspended app at the currently-pinned version. Distinct from deploy: does NOT bump configVersion. - `data-app stop --project NAME --app-id ID [--wait] [--timeout SECONDS]` -- stop a running app (URL and Storage config preserved). - `data-app delete --project NAME --app-id ID [--yes]` -- destructive, cascades to Storage config; URL retired permanently. -- `data-app password --project NAME --app-id ID` -- read the simpleAuth password. Requires `KBC_MANAGE_API_TOKEN`. Auto-generated, not rotatable -- delete + recreate to mint a new one. +- `data-app password --project NAME --app-id ID` -- read the simpleAuth password. Manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.28.0+. Auto-generated, not rotatable -- delete + recreate to mint a new one. ## MCP Tools - `tool list [--project NAME] [--branch ID]` -- list available MCP tools (multi_project annotation) @@ -186,7 +187,7 @@ Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, |----------|---------| | `KBC_TOKEN` | Fallback for `--token` | | `KBC_STORAGE_API_URL` | Default stack URL | -| `KBC_MANAGE_API_TOKEN` | Manage API token (org setup) | +| `KBC_MANAGE_API_TOKEN` | Manage API token (org setup, project refresh, data-app password). Default-DENY since 0.28.0: requires top-level `--allow-env-manage-token` to opt in, otherwise ignored with a warning. | | `KBAGENT_CONFIG_DIR` | Override config directory | ## Exit Codes diff --git a/plugins/kbagent/skills/kbagent/references/data-app-workflow.md b/plugins/kbagent/skills/kbagent/references/data-app-workflow.md index 7b840293..37cab40a 100644 --- a/plugins/kbagent/skills/kbagent/references/data-app-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/data-app-workflow.md @@ -58,7 +58,9 @@ with: ```bash kbagent data-app password --project prod --app-id -# Requires KBC_MANAGE_API_TOKEN in addition to the project's Storage token. +# Manage token: interactive prompt by default (since v0.28.0); for CI add +# --allow-env-manage-token alongside KBC_MANAGE_API_TOKEN. Storage token +# is read from .kbagent/config.json as usual. ``` The simpleAuth password CANNOT be rotated (writeup §11.2). To change it, diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 82321578..ef8a6953 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -1,5 +1,36 @@ # Gotchas -- Response Parsing and Common Pitfalls +## Manage token: env var is ignored without `--allow-env-manage-token` (since v0.28.0) + +- `KBC_MANAGE_API_TOKEN` is no longer auto-resolved on the three + surfaces that consume it (`kbagent org setup`, + `kbagent project refresh`, `kbagent data-app password`). Default + behaviour on 0.28.0+ is **default-deny**: the env var is ignored, a + TTY hidden-input prompt is shown instead. With no TTY (CI / cron / + systemd / `< /dev/null`) the resolver exits **2** with the message + `Error: No manage token available. Run interactively, or pass + --allow-env-manage-token to read KBC_MANAGE_API_TOKEN from env.` +- To opt in for CI/CD, pass the top-level flag: + `kbagent --allow-env-manage-token --json org setup ...`. The flag + belongs in front of the subcommand (it is a top-level option, mirroring + `--deny-writes`). The flag is session-only -- not persisted, no + env-var equivalent (intentional; an env-var equivalent would re-create + the AI-exfiltration hole this default-deny is closing). +- When the env var IS set but the flag IS NOT, you will see a one-shot + stderr warning `Warning: KBC_MANAGE_API_TOKEN found in environment + but ignored. Pass --allow-env-manage-token to opt in.`. This is + informational; the resolver still falls through to the TTY prompt + (or exits 2 if no TTY). Do NOT suppress this warning by piping stderr + away -- it tells CI maintainers exactly what to fix. +- The default-deny exists to close the AI-exfiltration risk: any + subprocess running as the same user (including the AI agent itself) + inherits env vars, so a manage token in env is reachable by anyone + who can read `os.environ` or shell out raw `curl`. Default-deny means + human admin work uses TTY (no env exposure) and CI must explicitly + say "yes I trust this env" via the flag. +- Storage tokens are unaffected: `KBC_TOKEN` (storage API) keeps + resolving from env as before. + ## `data-app deploy` is required after `config update` -- the running container does NOT auto-pick-up new config versions (since v0.27.0) - `kbagent config update --component-id keboola.data-apps ...` bumps the @@ -372,7 +403,7 @@ type inventory and examples. - Tokens are always masked in output (e.g. `901-...pt0k`) -- this is normal - Token can be passed via `--token`, `KBC_TOKEN` env var, or interactive prompt -- Manage API token: only via `KBC_MANAGE_API_TOKEN` env var or interactive prompt (never as CLI argument) +- Manage API token (since v0.28.0): default-deny on env -- via interactive hidden prompt; pass top-level `--allow-env-manage-token` to opt in to `KBC_MANAGE_API_TOKEN`. Never as CLI argument. See the `(since v0.28.0)` entry at the top of this file. - Master token for sharing: `KBC_MASTER_TOKEN_{ALIAS}` (e.g. `KBC_MASTER_TOKEN_PROD`) or `KBC_MASTER_TOKEN` as global fallback. Alias is uppercased, hyphens become underscores. Required for `sharing share` and `sharing unshare`; `sharing list/link/unlink` use regular project tokens. ## MCP tool call gotchas @@ -721,7 +752,7 @@ See [docs/hint-mode.md](../../../../../docs/hint-mode.md) for full documentation - **Forgetting `--json`**: without it, output is human-formatted Rich text, not parseable - **Assuming `data.projects`**: `project list` returns data as a flat list -- **Passing manage token as argument**: use env var `KBC_MANAGE_API_TOKEN` instead +- **Passing manage token as argument**: use the interactive prompt (default since v0.28.0), or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` env var for CI - **Polling after branch create**: kbagent already waits for async completion - **Not saving workspace password**: only returned once on creation - **Putting SQL in _config.yml**: SQL transformations must use `transform.sql` with block markers (see above) diff --git a/pyproject.toml b/pyproject.toml index 8884aef6..dfca5d5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.27.0" +version = "0.28.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index c0ee1a56..60561541 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,13 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.28.0": [ + "BREAKING: `KBC_MANAGE_API_TOKEN` is now ignored by default. The three commands that consume it (`org setup`, `project refresh`, `data-app password`) prompt for the token on a TTY by default. Pass the new top-level flag `--allow-env-manage-token` to restore the legacy env-var behaviour (e.g. for CI/CD). Without the flag and without a TTY, the resolver exits 2 with an actionable message naming the flag. The change closes the AI-exfiltration risk where any subprocess running as the same user (including the AI agent itself) inherits the manage token via env. Migration: prepend `--allow-env-manage-token` to existing CI invocations. Storage tokens (`KBC_TOKEN`) are unaffected. Closes the manage-token UX flagged on #236; supersedes the per-stack design discussed in #238.", + "Security: `resolve_manage_token` (`src/keboola_agent_cli/commands/_helpers.py`) refactored to default-deny env, TTY-first. When the env var is set but the flag is not passed, a one-shot stderr warning fires (`Warning: KBC_MANAGE_API_TOKEN found in environment but ignored. Pass --allow-env-manage-token to opt in.`) and the resolver falls through to the TTY prompt. No cache, no keyring, no temp file -- next invocation prompts again. The bulk-prompt-once contract (`project refresh --all`) is preserved by construction: the resolver lives at command entry, before any per-project loop.", + "New: top-level CLI flag `--allow-env-manage-token` (session-only, mirrors `--deny-writes` / `--deny-destructive`). Plumbed via `ctx.obj['allow_env_manage_token']` and forwarded by the three call sites into `resolve_manage_token(allow_env=...)`. Not persisted, no env-var equivalent (intentional; an env-var equivalent would re-create the AI-exfiltration hole this default-deny is closing).", + "Tests: 12 new (`tests/test_helpers.py::TestResolveManageToken` x7 covering allow_env-True/False x env-set/unset x TTY/non-TTY combinations + token-leak regression pin; `tests/test_manage_token_cli.py::TestAllowEnvManageTokenFlag` x4 covering project-refresh / org-setup / data-app-password through CliRunner with services mocked; `tests/test_manage_token_bulk.py::TestBulkPromptOnce` pinning the contract that `project refresh --all` resolves the token exactly once at command entry, not per-project).", + "Docs: `commands/context.py` AGENT_CONTEXT updated (org-setup example + env-var help block); `CLAUDE.md` convention #12 + global-flag list; `keboola-expert.md` Rule 6 VERSION GATE adds the 0.28.0+ env-flag requirement, tool-selection-matrix updated, new inline-gotcha block; `gotchas.md` new `(since v0.28.0)` entry naming the warning text and the one-line CI fix; `commands-reference.md` updated for `org setup`, `data-app password`, env-var table.", + ], "0.27.0": [ "New: `kbagent data-app` command group — first-class lifecycle for Keboola data apps (`keboola.data-apps` Storage component + Data Science API `/apps`). Eight subcommands: `list`, `detail`, `create`, `deploy`, `start`, `stop`, `delete`, `password`. The CLI encapsulates the **§9 redeploy contract** (always sends the `{desiredState=running, configVersion, restartIfRunning=true}` trio together; without it, `PATCH /apps {desiredState:running}` silently pins to the empty-shell v2 and the runner errors `dataApp.git.repository is required in /data/config.json`), per-project KMS encryption of git PATs (refuses to write plaintext if the Encryption API does not return a project-scoped ciphertext), cleanup-in-finally on initial-deploy failure (orphan shell deleted by default; `--keep-on-failure` opts out), and a poll loop that respects pitfall #1 — `state == stopped` is NOT terminal while `desiredState == running` (the platform transitions `created → stopped → starting → running` during initial deploy). `data-app create` accepts `--git-pat-env VAR` (recommended; no argv leak), `--git-pat-file PATH`, or `--git-pat-encrypted KBC::Project...` (must be encrypted under THIS project's KMS — ciphertext does not cross projects).", "New: `DataScienceClient` (`src/keboola_agent_cli/data_science_client.py`) — third HTTP client class alongside `KeboolaClient` and `AiServiceClient`. Auth via `X-StorageApi-Token`; URL derived as `data-science.{stack-suffix}` from the connection URL; inherits `BaseHttpClient` for retry/backoff/token-masking. `get_app_password()` accepts the Manage token per-call so it never lives on the persistent client.", diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 3429b1ec..e97b8b54 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -220,6 +220,14 @@ def main( "branch delete, etc.). Admin ops like 'project remove' and 'org setup' " "are NOT blocked -- use --deny-writes for the wide net.", ), + allow_env_manage_token: bool = typer.Option( + False, + "--allow-env-manage-token", + help="Read KBC_MANAGE_API_TOKEN from the environment. Without this " + "flag the env var is ignored (with a warning) and an interactive " + "TTY prompt is required. Default-deny since 0.28.0; closes the " + "AI-exfiltration risk where subprocesses inherit the manage token.", + ), ) -> None: """Global options applied to all commands.""" from .auto_update import maybe_auto_update, show_post_update_changelog @@ -339,6 +347,7 @@ def main( ctx.obj["no_color"] = effective_no_color ctx.obj["deny_writes"] = deny_writes ctx.obj["deny_destructive"] = deny_destructive + ctx.obj["allow_env_manage_token"] = allow_env_manage_token ctx.obj["config_store"] = config_store ctx.obj["project_service"] = project_service ctx.obj["component_service"] = component_service diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index a9e9ed15..64cce61c 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -24,31 +24,49 @@ from ..output import OutputFormatter -def resolve_manage_token() -> str: - """Resolve the manage token from env var or interactive prompt. +def resolve_manage_token(*, allow_env: bool = False) -> str: + """Resolve the manage token from a permitted source. - Token resolution order: - 1. KBC_MANAGE_API_TOKEN env var (for CI/CD) - 2. Interactive prompt with hidden input (if TTY) - 3. Error if neither available + Default-deny: KBC_MANAGE_API_TOKEN is ignored unless ``allow_env=True`` + (set by the top-level ``--allow-env-manage-token`` flag). The change + closes the AI-exfiltration risk where any subprocess running as the + same user can read the env var; the new default is "human at a TTY". + + Resolution order: + 1. ``KBC_MANAGE_API_TOKEN`` env var, IF ``allow_env`` is True. Otherwise + a one-shot stderr warning is emitted and the env var is ignored. + 2. Interactive prompt with hidden input (if stdin is a TTY). + 3. Exit 2 with an actionable error naming the opt-in flag. + + Args: + allow_env: When True, restores the legacy env-var-first behaviour + for the current invocation. Plumbed from the top-level + ``--allow-env-manage-token`` flag via ``ctx.obj``. Returns: The manage API token. Raises: - typer.Exit: If no token can be resolved. + typer.Exit: If no token can be resolved (exit code 2). """ env_token = os.environ.get(ENV_KBC_MANAGE_API_TOKEN) if env_token: - return env_token + if allow_env: + return env_token + typer.echo( + f"Warning: {ENV_KBC_MANAGE_API_TOKEN} found in environment " + "but ignored. Pass --allow-env-manage-token to opt in.", + err=True, + ) is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty() if is_tty: return typer.prompt("Manage API token", hide_input=True) typer.echo( - f"Error: No manage token available. Set {ENV_KBC_MANAGE_API_TOKEN} env var " - "or run interactively.", + "Error: No manage token available. Run interactively, or pass " + f"--allow-env-manage-token to read {ENV_KBC_MANAGE_API_TOKEN} " + "from env.", err=True, ) raise typer.Exit(code=2) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index ea00b6d5..243432a8 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -35,7 +35,7 @@ kbagent --json project add --project my-project --url https://connection.keboola.com --token YOUR_TOKEN # Or bulk-onboard all projects from an organization - KBC_MANAGE_API_TOKEN=xxx kbagent --json org setup --org-id 123 --url https://connection.keboola.com --yes + KBC_MANAGE_API_TOKEN=xxx kbagent --allow-env-manage-token --json org setup --org-id 123 --url https://connection.keboola.com --yes # Explore kbagent --json project list @@ -403,7 +403,10 @@ kbagent org setup --project-ids 901,9621,10539 --url URL [--dry-run] [--yes] [--refresh] Non-admin mode: onboard specific projects by ID. Works with Personal Access Token (PAT). Use --org-id OR --project-ids (at least one required). - Token via KBC_MANAGE_API_TOKEN env var or interactive prompt. + Token via interactive hidden prompt by default; pass top-level + --allow-env-manage-token to read KBC_MANAGE_API_TOKEN from env (CI/CD). + Default-deny since 0.28.0 -- closes the AI-exfiltration risk where + subprocesses inherit the manage token via env. ### Flows (Orchestrator + Conditional) @@ -577,11 +580,12 @@ URL is permanently retired. Confirmation prompt unless --yes. kbagent data-app password --project NAME --app-id ID - Retrieve the simpleAuth password. Requires KBC_MANAGE_API_TOKEN in - addition to the project's Storage token. Token is read from env or - interactive hidden prompt; never persisted, never logged. Password is - auto-generated at create time and CANNOT be rotated -- delete and - recreate the app to mint a new one. + Retrieve the simpleAuth password. Requires the Manage API token in + addition to the project's Storage token. Token is read from interactive + hidden prompt by default; pass top-level --allow-env-manage-token to + use KBC_MANAGE_API_TOKEN from env (default-deny since 0.28.0). Never + persisted, never logged. Password is auto-generated at create time + and CANNOT be rotated -- delete and recreate the app to mint a new one. ### Project Sync @@ -716,7 +720,10 @@ KBAGENT_CONVERSATION_ID Conversation/session ID (REQUIRED -- sent as X-Conversation-ID header) KBC_TOKEN Storage API token (fallback for --token) KBC_STORAGE_API_URL Default stack URL (fallback for --url) - KBC_MANAGE_API_TOKEN Manage API token (for org setup) + KBC_MANAGE_API_TOKEN Manage API token (org setup, project refresh, data-app password). + Default-DENY since 0.28.0: pass --allow-env-manage-token + to opt in, otherwise this var is ignored and a TTY prompt + is required. Closes AI-exfiltration via subprocess env. KBC_MASTER_TOKEN Master token for sharing ops (global fallback) KBC_MASTER_TOKEN_* Per-project master token (e.g. KBC_MASTER_TOKEN_PROD) KBAGENT_CONFIG_DIR Override config directory diff --git a/src/keboola_agent_cli/commands/data_app.py b/src/keboola_agent_cli/commands/data_app.py index aeff0d0f..52022808 100644 --- a/src/keboola_agent_cli/commands/data_app.py +++ b/src/keboola_agent_cli/commands/data_app.py @@ -583,15 +583,17 @@ def data_app_password( ) -> None: """Retrieve the simpleAuth password for a password-gated data app. - Requires KBC_MANAGE_API_TOKEN in addition to the project's Storage - token. Token is read from env or interactive prompt; never persisted. + Requires the Manage API token in addition to the project's Storage + token. Default-deny since 0.28.0: read from an interactive hidden + prompt; pass top-level --allow-env-manage-token to read + KBC_MANAGE_API_TOKEN from env (CI/CD). Never persisted, never logged. """ if should_hint(ctx): emit_hint(ctx, "data-app.password", project=project, app_id=app_id) return formatter = get_formatter(ctx) service = get_service(ctx, "data_app_service") - manage_token = resolve_manage_token() + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) try: result = service.get_data_app_password( diff --git a/src/keboola_agent_cli/commands/org.py b/src/keboola_agent_cli/commands/org.py index 25b95638..358eb2f2 100644 --- a/src/keboola_agent_cli/commands/org.py +++ b/src/keboola_agent_cli/commands/org.py @@ -202,8 +202,10 @@ def org_setup( Creates Storage API tokens and registers projects. Safe to re-run -- already registered projects are skipped. - The token is read from KBC_MANAGE_API_TOKEN env var or prompted - interactively (never passed as a CLI argument for security). + The Manage API token is read from an interactive hidden prompt by + default (since 0.28.0). Pass the top-level --allow-env-manage-token + flag to read KBC_MANAGE_API_TOKEN from env (CI/CD). Never passed as + a CLI argument. """ if should_hint(ctx): emit_hint(ctx, "org.setup", org_id=org_id, url=url, dry_run=dry_run) @@ -220,7 +222,7 @@ def org_setup( ) raise typer.Exit(code=2) - manage_token = resolve_manage_token() + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) # Build kwargs shared by preview and real call setup_kwargs: dict = { diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 409e94ea..ccb41a76 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -394,7 +394,9 @@ def project_refresh( """Refresh expired or invalid Storage API tokens. Creates new tokens via the Manage API and updates the local config. - Requires a Manage API token (via KBC_MANAGE_API_TOKEN env var or interactive prompt). + Requires a Manage API token: interactive hidden prompt by default + (since 0.28.0); pass top-level --allow-env-manage-token to read + KBC_MANAGE_API_TOKEN from env (CI/CD). \b Examples: @@ -420,7 +422,7 @@ def project_refresh( ) raise typer.Exit(code=2) - manage_token = resolve_manage_token() + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) aliases = [project] if project else None diff --git a/src/keboola_agent_cli/commands/repl.py b/src/keboola_agent_cli/commands/repl.py index 3770af74..7e8d80ff 100644 --- a/src/keboola_agent_cli/commands/repl.py +++ b/src/keboola_agent_cli/commands/repl.py @@ -74,13 +74,17 @@ def _run_repl( config_dir: str | None, deny_writes: bool = False, deny_destructive: bool = False, + allow_env_manage_token: bool = False, ) -> None: """Main REPL loop. Global flags from the outer invocation are re-applied on every command executed inside the REPL. This includes the session-only firewall flags ``--deny-writes`` / ``--deny-destructive`` -- dropping them here would - silently elevate the REPL above the policy the user started it with. + silently elevate the REPL above the policy the user started it with -- + and ``--allow-env-manage-token``, which would otherwise force re-prompts + on every nested ``org setup`` / ``project refresh`` / ``data-app + password`` even after the user opted in at the outer invocation. """ from ..cli import app as typer_app @@ -169,6 +173,8 @@ def _run_repl( full_argv.append("--deny-writes") if deny_destructive and "--deny-destructive" not in argv: full_argv.append("--deny-destructive") + if allow_env_manage_token and "--allow-env-manage-token" not in argv: + full_argv.append("--allow-env-manage-token") full_argv.extend(argv) # Prevent recursive REPL @@ -206,4 +212,5 @@ def repl_command(ctx: typer.Context) -> None: config_dir=None, # Already resolved in ctx deny_writes=ctx.obj.get("deny_writes", False), deny_destructive=ctx.obj.get("deny_destructive", False), + allow_env_manage_token=ctx.obj.get("allow_env_manage_token", False), ) diff --git a/src/keboola_agent_cli/hints/definitions/data_app.py b/src/keboola_agent_cli/hints/definitions/data_app.py index 9a95b080..1581d677 100644 --- a/src/keboola_agent_cli/hints/definitions/data_app.py +++ b/src/keboola_agent_cli/hints/definitions/data_app.py @@ -393,8 +393,11 @@ notes=[ "Password is auto-generated at app create time and cannot be rotated. " "Delete + recreate the app to mint a new one (writeup §11.2).", - "Manage token is read from KBC_MANAGE_API_TOKEN env var or interactive " - "hidden prompt; never persisted, never logged.", + "Manage token is read from an interactive hidden prompt by default " + "(since kbagent v0.28.0). For non-interactive runners, the calling " + "kbagent invocation must pass `--allow-env-manage-token` to opt in " + "to KBC_MANAGE_API_TOKEN env-var resolution. Never persisted, never " + "logged.", ], ) ) diff --git a/src/keboola_agent_cli/hints/definitions/org.py b/src/keboola_agent_cli/hints/definitions/org.py index c704fec7..d033fdd8 100644 --- a/src/keboola_agent_cli/hints/definitions/org.py +++ b/src/keboola_agent_cli/hints/definitions/org.py @@ -32,7 +32,9 @@ ), ], notes=[ - "Uses the Manage API with KBC_MANAGE_API_TOKEN (not Storage token).", + "Uses the Manage API token (not Storage token). Default-deny on env " + "since kbagent v0.28.0: pass top-level `--allow-env-manage-token` " + "to read KBC_MANAGE_API_TOKEN, or run interactively (TTY hidden prompt).", "Service layer creates per-project tokens and registers them in CLI config.", ], ) diff --git a/src/keboola_agent_cli/services/data_app_service.py b/src/keboola_agent_cli/services/data_app_service.py index d5d4b1e7..c7afe74a 100644 --- a/src/keboola_agent_cli/services/data_app_service.py +++ b/src/keboola_agent_cli/services/data_app_service.py @@ -747,7 +747,8 @@ def get_data_app_password( raise KeboolaApiError( message=( "Manage API token is required to read the data-app simpleAuth " - "password. Set KBC_MANAGE_API_TOKEN or run interactively." + "password. Run interactively (default since v0.28.0), or pass " + "--allow-env-manage-token + set KBC_MANAGE_API_TOKEN for CI." ), status_code=0, error_code=ErrorCode.INVALID_TOKEN, diff --git a/tests/test_cli.py b/tests/test_cli.py index 8ea5c3d0..c821b1e6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6826,7 +6826,8 @@ def test_org_setup_dry_run(self, tmp_path: Path) -> None: assert output["data"]["projects_added"][0]["action"] == "would_add" def test_org_setup_with_env_token(self, tmp_path: Path) -> None: - """org setup uses KBC_MANAGE_API_TOKEN env var for authentication.""" + """org setup uses KBC_MANAGE_API_TOKEN env var for authentication + when the caller opts in via --allow-env-manage-token (since 0.28.0).""" config_dir = tmp_path / "config" config_dir.mkdir() @@ -6848,6 +6849,7 @@ def test_org_setup_with_env_token(self, tmp_path: Path) -> None: result = runner.invoke( app, [ + "--allow-env-manage-token", "--json", "org", "setup", @@ -7259,11 +7261,16 @@ class TestResolveManageToken: """Tests for resolve_manage_token() in _helpers.py.""" def test_token_from_env(self) -> None: - """resolve_manage_token returns token from KBC_MANAGE_API_TOKEN env var.""" + """resolve_manage_token(allow_env=True) returns token from KBC_MANAGE_API_TOKEN env var. + + Default-deny since 0.28.0: callers must pass allow_env=True to opt in + to env-var resolution. The opt-in is plumbed from the top-level + --allow-env-manage-token flag. + """ from keboola_agent_cli.commands._helpers import resolve_manage_token with patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": "env-manage-token"}): - token = resolve_manage_token() + token = resolve_manage_token(allow_env=True) assert token == "env-manage-token" diff --git a/tests/test_data_app_cli.py b/tests/test_data_app_cli.py index fe5b725c..6aabd279 100644 --- a/tests/test_data_app_cli.py +++ b/tests/test_data_app_cli.py @@ -335,6 +335,7 @@ def test_password_success(self, tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "manage-token") result = _invoke( [ + "--allow-env-manage-token", "--json", "data-app", "password", diff --git a/tests/test_helpers.py b/tests/test_helpers.py index ca1dbeab..fca52476 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -518,3 +518,131 @@ def test_flags_do_not_mutate_persisted(self) -> None: before = list(persisted.deny) apply_firewall_flags(persisted, deny_writes=True, deny_destructive=True) assert persisted.deny == before, "persisted.deny was mutated in place" + + +class TestResolveManageToken: + """Tests for resolve_manage_token default-deny + opt-in behaviour (since 0.28.0). + + The contract: KBC_MANAGE_API_TOKEN is ignored unless the caller passes + allow_env=True (plumbed from --allow-env-manage-token at the CLI). When + ignored, a one-shot stderr warning is emitted and the resolver falls + through to the TTY-prompt path. With no env and no TTY, it exits 2 with + an error naming the opt-in flag. + """ + + _SENTINEL = "kbagent-test-sentinel-token-9c4f" + + def test_returns_env_when_allow_env_true( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + from keboola_agent_cli.commands._helpers import resolve_manage_token + + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", self._SENTINEL) + result = resolve_manage_token(allow_env=True) + assert result == self._SENTINEL + captured = capsys.readouterr() + assert "found in environment but ignored" not in captured.err + assert self._SENTINEL not in captured.out + assert self._SENTINEL not in captured.err + + def test_default_deny_warns_and_falls_through_to_tty( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + from keboola_agent_cli.commands import _helpers + from keboola_agent_cli.commands._helpers import resolve_manage_token + + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", self._SENTINEL) + # Force the TTY branch. + monkeypatch.setattr(_helpers.sys.stdin, "isatty", lambda: True, raising=False) + # typer.prompt would block on real stdin in tests; replace it. + monkeypatch.setattr(_helpers.typer, "prompt", lambda *a, **k: "from-prompt") + result = resolve_manage_token() # allow_env defaults False + assert result == "from-prompt" + err = capsys.readouterr().err + assert "KBC_MANAGE_API_TOKEN found in environment" in err + assert "--allow-env-manage-token" in err + + def test_default_deny_no_tty_no_env_exits_2( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + import typer + + from keboola_agent_cli.commands import _helpers + from keboola_agent_cli.commands._helpers import resolve_manage_token + + monkeypatch.delenv("KBC_MANAGE_API_TOKEN", raising=False) + monkeypatch.setattr(_helpers.sys.stdin, "isatty", lambda: False, raising=False) + with pytest.raises(typer.Exit) as exc_info: + resolve_manage_token() + assert exc_info.value.exit_code == 2 + err = capsys.readouterr().err + assert "--allow-env-manage-token" in err + assert "Run interactively" in err + + def test_default_deny_with_env_no_tty_warns_then_exits_2( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + import typer + + from keboola_agent_cli.commands import _helpers + from keboola_agent_cli.commands._helpers import resolve_manage_token + + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", self._SENTINEL) + monkeypatch.setattr(_helpers.sys.stdin, "isatty", lambda: False, raising=False) + with pytest.raises(typer.Exit) as exc_info: + resolve_manage_token() + assert exc_info.value.exit_code == 2 + err = capsys.readouterr().err + # Both messages on stderr in this branch. + assert "found in environment but ignored" in err + assert "Run interactively" in err + # And the sentinel is never echoed. + assert self._SENTINEL not in err + + def test_no_env_tty_prompts_normally( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + from keboola_agent_cli.commands import _helpers + from keboola_agent_cli.commands._helpers import resolve_manage_token + + monkeypatch.delenv("KBC_MANAGE_API_TOKEN", raising=False) + monkeypatch.setattr(_helpers.sys.stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr(_helpers.typer, "prompt", lambda *a, **k: "tty-token") + result = resolve_manage_token() + assert result == "tty-token" + err = capsys.readouterr().err + assert "found in environment but ignored" not in err + + def test_token_value_never_appears_in_captured_output( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """Regression pin: the sentinel must never leak to stdout/stderr.""" + from keboola_agent_cli.commands._helpers import resolve_manage_token + + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", self._SENTINEL) + result = resolve_manage_token(allow_env=True) + captured = capsys.readouterr() + assert result == self._SENTINEL # returned, but not printed + assert self._SENTINEL not in captured.out + assert self._SENTINEL not in captured.err + + def test_allow_env_with_unset_env_and_no_tty_exits_2( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """The flag is permission, not promise: with the flag set but no env + AND no TTY, we still exit 2. Pins the no-source-available failure + mode for cron/CI diagnostics.""" + import typer + + from keboola_agent_cli.commands import _helpers + from keboola_agent_cli.commands._helpers import resolve_manage_token + + monkeypatch.delenv("KBC_MANAGE_API_TOKEN", raising=False) + monkeypatch.setattr(_helpers.sys.stdin, "isatty", lambda: False, raising=False) + with pytest.raises(typer.Exit) as exc_info: + resolve_manage_token(allow_env=True) + assert exc_info.value.exit_code == 2 + err = capsys.readouterr().err + assert "Run interactively" in err + # No phantom warning when env was actually empty. + assert "found in environment but ignored" not in err diff --git a/tests/test_manage_token_bulk.py b/tests/test_manage_token_bulk.py new file mode 100644 index 00000000..814969d3 --- /dev/null +++ b/tests/test_manage_token_bulk.py @@ -0,0 +1,87 @@ +"""Pin the bulk-prompt-once contract for resolve_manage_token (since 0.28.0). + +Padak's review on PR #238 explicitly required: when a single command +invocation processes N projects (e.g. `kbagent project refresh --all`), +the manage-token TTY prompt must fire **exactly once** at command entry, +never per-project. That contract holds today by construction: the +resolver lives at command entry, before any per-project loop. This test +pins it so a future refactor that pushes resolution into the service +loop fails loudly. +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.models import AppConfig, ProjectConfig + +runner = CliRunner() + +TEST_STORAGE_TOKEN = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + + +def _make_store_n_projects(tmp_path: Path, n: int) -> ConfigStore: + """Create a ConfigStore with N registered projects on different stacks.""" + config_dir = tmp_path / "config" + config_dir.mkdir(exist_ok=True) + store = ConfigStore(config_dir=config_dir) + projects = {} + for i in range(n): + # Spread across multiple stacks to make the bulk-once contract + # meaningful: a per-project loop would naively prompt N times, + # potentially with different stack URLs visible in the prompt. + stack = ["us-east4.gcp", "eu-central-1", "north-europe.azure"][i % 3] + projects[f"proj-{i}"] = ProjectConfig( + stack_url=f"https://connection.{stack}.keboola.com", + token=TEST_STORAGE_TOKEN, + ) + store.save(AppConfig(projects=projects)) + return store + + +class TestBulkPromptOnce: + def test_project_refresh_all_resolves_token_once_for_n_projects(self, tmp_path: Path) -> None: + """`project refresh --all` with 5 projects on 3 stacks must call + ``resolve_manage_token`` exactly once. The resolver lives at command + entry; pushing it into the per-project loop would prompt N times + (or N times with different stack URLs). This test fails loudly if + a future refactor moves the call into a loop. + + Patching ``resolve_manage_token`` directly (rather than the inner + TTY prompt) is the right granularity: it tests the command-layer + contract independently of how the resolver decides between env + and TTY internally.""" + store = _make_store_n_projects(tmp_path, 5) + resolver_mock = MagicMock(return_value="bulk-resolved-token") + mock_org = MagicMock() + mock_org.refresh_tokens.return_value = { + "status": "ok", + "refreshed": [], + "errors": [], + } + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.OrgService") as MockOrgService, + patch( + "keboola_agent_cli.commands.project.resolve_manage_token", + resolver_mock, + ), + ): + MockStore.return_value = store + MockOrgService.return_value = mock_org + result = runner.invoke(app, ["--json", "project", "refresh", "--all", "--yes"]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + # The contract: exactly one resolver call regardless of project count. + assert resolver_mock.call_count == 1, ( + f"Expected resolve_manage_token to be called once for --all " + f"across 5 projects, got {resolver_mock.call_count} calls -- " + f"per-project resolution regression. Manage-token resolution " + f"must live at command entry, never in a per-project loop." + ) + # The service receives the resolved token and fans out internally. + assert mock_org.refresh_tokens.call_count == 1 + assert mock_org.refresh_tokens.call_args.kwargs["manage_token"] == "bulk-resolved-token" diff --git a/tests/test_manage_token_cli.py b/tests/test_manage_token_cli.py new file mode 100644 index 00000000..1d875925 --- /dev/null +++ b/tests/test_manage_token_cli.py @@ -0,0 +1,188 @@ +"""Tests for the --allow-env-manage-token top-level flag (since 0.28.0). + +Pins the contract: KBC_MANAGE_API_TOKEN is ignored by default; passing +--allow-env-manage-token at the top level restores the legacy env-var +resolution. The flag is session-only (not persisted), mirroring +--deny-writes / --deny-destructive. + +Three CLI surfaces consume the manage token: `org setup`, +`project refresh`, and `data-app password`. Each is tested in both +modes: default-deny (asserts the service was never reached) and +allow-env (asserts the service received manage_token=). +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.models import AppConfig, ProjectConfig + +runner = CliRunner() + +TEST_STORAGE_TOKEN = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" +SENTINEL_MANAGE_TOKEN = "manage-sentinel-7af3e9c1-test-only" + + +def _make_store(tmp_path: Path) -> ConfigStore: + """Create a ConfigStore with a single registered project.""" + config_dir = tmp_path / "config" + config_dir.mkdir(exist_ok=True) + store = ConfigStore(config_dir=config_dir) + config = AppConfig( + projects={ + "prod": ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_STORAGE_TOKEN, + ) + } + ) + store.save(config) + return store + + +class TestAllowEnvManageTokenFlag: + """Default-deny env + opt-in flag for resolve_manage_token.""" + + def test_project_refresh_default_ignores_env_warns_no_tty_exits_2( + self, tmp_path: Path, monkeypatch + ) -> None: + """Without --allow-env-manage-token, env is ignored, no TTY -> exit 2. + The OrgService.refresh_tokens call site must never be reached.""" + store = _make_store(tmp_path) + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", SENTINEL_MANAGE_TOKEN) + + mock_org = MagicMock() + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.OrgService") as MockOrgService, + ): + MockStore.return_value = store + MockOrgService.return_value = mock_org + result = runner.invoke( + app, ["--json", "project", "refresh", "--project", "prod", "--yes"] + ) + + assert result.exit_code == 2, f"Exit code {result.exit_code}: {result.output}" + # CliRunner combines stdout+stderr; the warning must appear somewhere. + assert "found in environment but ignored" in result.output + assert "--allow-env-manage-token" in result.output + # Crucial: the service was never called. + mock_org.refresh_tokens.assert_not_called() + # The sentinel must not leak into output. + assert SENTINEL_MANAGE_TOKEN not in result.output + + def test_project_refresh_allow_env_uses_env_token(self, tmp_path: Path, monkeypatch) -> None: + """With --allow-env-manage-token, env is honoured and forwarded + to OrgService.refresh_tokens as manage_token kwarg.""" + store = _make_store(tmp_path) + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", SENTINEL_MANAGE_TOKEN) + + mock_org = MagicMock() + mock_org.refresh_tokens.return_value = { + "status": "ok", + "refreshed": [], + "errors": [], + } + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.OrgService") as MockOrgService, + ): + MockStore.return_value = store + MockOrgService.return_value = mock_org + result = runner.invoke( + app, + [ + "--allow-env-manage-token", + "--json", + "project", + "refresh", + "--project", + "prod", + "--yes", + ], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + # Service called once, with the env-supplied manage_token. + assert mock_org.refresh_tokens.call_count == 1 + kwargs = mock_org.refresh_tokens.call_args.kwargs + assert kwargs["manage_token"] == SENTINEL_MANAGE_TOKEN + # Even though the call succeeded, the sentinel must not appear in + # JSON output (the resolver and service handle masking). + assert SENTINEL_MANAGE_TOKEN not in result.output + + def test_data_app_password_default_deny_no_tty_exits_2( + self, tmp_path: Path, monkeypatch + ) -> None: + """data-app password mirrors project refresh: env ignored without + the flag, service never called.""" + store = _make_store(tmp_path) + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", SENTINEL_MANAGE_TOKEN) + + mock_data_app = MagicMock() + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.DataAppService") as MockDataAppService, + ): + MockStore.return_value = store + MockDataAppService.return_value = mock_data_app + result = runner.invoke( + app, + [ + "--json", + "data-app", + "password", + "--project", + "prod", + "--app-id", + "1", + ], + ) + + assert result.exit_code == 2 + assert "found in environment but ignored" in result.output + assert "--allow-env-manage-token" in result.output + mock_data_app.get_data_app_password.assert_not_called() + assert SENTINEL_MANAGE_TOKEN not in result.output + + def test_org_setup_allow_env_passes_token_through(self, tmp_path: Path, monkeypatch) -> None: + """org setup with --allow-env-manage-token forwards the env token + to OrgService.setup_organization.""" + store = _make_store(tmp_path) + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", SENTINEL_MANAGE_TOKEN) + + mock_org = MagicMock() + mock_org.setup_organization.return_value = { + "status": "ok", + "registered": [], + "errors": [], + } + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.OrgService") as MockOrgService, + ): + MockStore.return_value = store + MockOrgService.return_value = mock_org + result = runner.invoke( + app, + [ + "--allow-env-manage-token", + "--json", + "org", + "setup", + "--org-id", + "1", + "--url", + "https://connection.keboola.com", + "--yes", + ], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + assert mock_org.setup_organization.call_count == 1 + kwargs = mock_org.setup_organization.call_args.kwargs + assert kwargs["manage_token"] == SENTINEL_MANAGE_TOKEN + assert SENTINEL_MANAGE_TOKEN not in result.output diff --git a/uv.lock b/uv.lock index 0363ff09..d39aa76e 100644 --- a/uv.lock +++ b/uv.lock @@ -439,7 +439,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.27.0" +version = "0.28.0" source = { editable = "." } dependencies = [ { name = "httpx" },