From f04d070a742160e649511724fe982363fbb6e375 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.md | 4 +- README.md | 12 +- docs/TUTORIAL.md | 39 +++- docs/e2e-scenarios.md | 4 +- plugins/kbagent/agents/keboola-expert.md | 20 +- 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 +++- src/keboola_agent_cli/changelog.py | 5 + 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 ++++++++++++++++++ 25 files changed, 611 insertions(+), 61 deletions(-) create mode 100644 tests/test_manage_token_bulk.py create mode 100644 tests/test_manage_token_cli.py diff --git a/CLAUDE.md b/CLAUDE.md index 3e6cb5eb..56081a6a 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/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 4e2d07fd..6b5c2f77 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -64,8 +64,12 @@ 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` +<<<<<<< HEAD need 0.27.0+, `config update` script[] auto-normalize against #245 trap needs 0.28.0+, `storage swap-tables` needs 0.28.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: . @@ -106,7 +110,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 @@ -273,6 +277,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 46e737b1..27cb91bb 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -273,8 +273,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 776f5edd..71836b9a 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`) @@ -126,7 +127,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) @@ -187,7 +188,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 936cc818..0ac8e63d 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -18,6 +18,37 @@ original table now carries the typed schema with no downstream config rewrite required. +## 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 @@ -390,7 +421,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 @@ -800,7 +831,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/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index ff96ce81..5430db77 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -17,6 +17,11 @@ "New: `kbagent storage swap-tables --project P --table-id A --target-table-id B [--branch ID] [--dry-run] [--yes]` -- thin wrapper around the Storage API `POST /v2/storage/branch/{branch}/tables/{id}/swap` endpoint. Both tables exchange physical positions; aliases are NOT transferred (they keep pointing at the same physical position and therefore expose the OTHER table's data after the swap). The Storage API queues this as an async storage job (`operationName: tableSwap`); the client polls to completion before returning, so callers can rely on the schemas already being exchanged on return (~10s observed on Snowflake). The API restricts this to dev branches; the service refuses with exit 5 (`ConfigError`) before any HTTP call when neither `--branch` nor an active branch (via `kbagent branch use`) is set. Same-source-and-target IDs also rejected pre-flight. The use case is: AI agent profiles a typeless table, builds a typed rebuild via CTAS in a workspace, then swaps the typed copy into the original name without touching downstream config references that point at the original table ID. Permission classification: `destructive` (gated behind `--allow-destructive`). The PHP reference client docstring claims a synchronous response, but live calls against the platform consistently return a queued job -- this client polls the job to completion to make the `delete_table` / `create_table` semantics consistent. Companion entry in `storage-types-workflow.md` explains the typify-via-CTAS pattern; gotchas + commands-reference + agent prompt all updated.", "Tests (swap-tables): `tests/test_storage_swap.py` (14 tests) covers all three layers -- HTTP shape (POST + body + URL encoding + immediate-success path + async-poll path + 4xx propagation via `pytest_httpx`), service business logic (success, dry-run, branch enforcement, same-id guard, API error propagation, unknown project), and CLI integration (JSON happy path, dry-run, explicit `--branch` overrides active, missing-branch error path with exit 5). E2E coverage in `tests/test_e2e.py::TestE2EStorageSwapTables` runs three scenarios against a live API: live swap of two tables with different VARCHAR lengths verifies definitions exchange in both directions; dry-run skips API call and `lastChangeDate` is unchanged; and the production-rejection path (no branch + no active branch) returns exit 5.", "Plugin docs: new `plugins/kbagent/skills/kbagent/references/typify-table-workflow.md` -- end-to-end procedure for converting a typeless Storage table (every column `STRING(16M)`) into one with proper Snowflake / BigQuery native types. 8 phases: (0) decide-or-skip rubric; (1) isolate in dev branch; (2) profile the typeless table in a workspace with length / cardinality / parse-failure / scale-precision queries + decision matrix mapping profile signals to Snowflake types; (3) build typed sibling via `storage create-table` + copy data via in-workspace INSERT or SQL transformation, with row-count / NULL-count verification; (4) validate downstream consumers in the dev branch (search configs that reference the table, run a representative transformation against the typeless source as baseline); (5) `swap-tables` (dry-run + actual + verify); (6) re-run downstream as smoke test; (7) cleanup the sibling after merge; (8) handoff protocol -- structured summary the AI agent hands to the user with phase-by-phase receipts, the merge URL, and rollback / cleanup commands. Cross-references `storage-types-workflow.md`, `branch-workflow.md`, `workspace-workflow.md`, `gotchas.md`. SKILL.md workflow-references table gains the new entry.", + "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).", 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 048d1e91..434283ef 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 @@ -419,7 +419,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) @@ -593,11 +596,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 @@ -732,7 +736,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