diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 703a3762..d73dfcf6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.27.0", + "version": "0.27.1", "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..dfae39fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, --no-env-manage-token kbagent project add --project NAME --url URL --token TOKEN kbagent project list @@ -383,9 +383,17 @@ kbagent schedule detail --project NAME --schedule-id ID [--branch ID] kbagent schedule find [--cron-window START-END] [--not-run-since DAYS] [--project NAME ...] [--branch ID] kbagent context -kbagent init [--from-global] +kbagent init [--from-global] [--read-only] kbagent doctor [--fix] kbagent version kbagent update kbagent changelog [--limit N] + +kbagent permissions list [--category read|write|destructive|admin] +kbagent permissions show +kbagent permissions set --mode allow|deny [--allow PATTERN ...] [--deny PATTERN ...] +kbagent permissions reset +kbagent permissions deny-manage-env +kbagent permissions allow-manage-env +kbagent permissions check OPERATION ``` diff --git a/docs/manage-token-design.md b/docs/manage-token-design.md new file mode 100644 index 00000000..bcc1e6b6 --- /dev/null +++ b/docs/manage-token-design.md @@ -0,0 +1,535 @@ +# Manage-token resolution design + +> Branched off `upstream/main` at `10ba4a0` (= v0.27.0). NOT yet +> committed for upstream review; this doc is the brief that goes to +> Padak with a structured comment on PR #236 before any production +> code is written. + +## 1. Problem + +Today every Manage API call site in `kbagent` resolves the token via +the same single-stack helper: + +```python +# src/keboola_agent_cli/commands/_helpers.py:27-54 +def resolve_manage_token() -> str: + env_token = os.environ.get(ENV_KBC_MANAGE_API_TOKEN) # KBC_MANAGE_API_TOKEN + if env_token: + return env_token + if sys.stdin.isatty(): + return typer.prompt("Manage API token", hide_input=True) + raise typer.Exit(code=2) +``` + +Three call sites at `upstream/main`: + +| File:line | Command | What's in scope | +|---|---|---| +| `commands/org.py:223` | `org setup` | `--url URL` (stack URL is the flag) | +| `commands/project.py:423` | `project refresh [--project A \| --all]` | Either one alias (→ `stack_url` via ConfigStore) or N aliases each with their own `stack_url` | +| `commands/data_app.py:594` | `data-app password` | Project alias (→ `stack_url` via ConfigStore) | + +A fourth call site arrives when **PR #236** lands +(`feat/project-member-invite`, currently `OPEN` and `CONFLICTING`): +`MemberService` uses Manage API for invite / member-list / member- +remove / set-role. Per Padak's review on #236, the maintainer flagged +two real problems with the single-env model — both before this PR +attempts a fix. + +### Problem 1 — Multi-stack ambiguity + +Manage tokens are stack-scoped. `connection.keboola.com` (legacy AWS US), +`connection.eu-central-1.keboola.com`, `connection.us-east4.gcp.keboola.com`, +`connection.north-europe.azure.keboola.com` — every stack mints its own. +But `kbagent` users routinely register projects across multiple stacks +in one `config.json`, and: + +- `OrgService.refresh_tokens` constructs a single `ManageClient` from + `projects_to_check[0][1].stack_url` (`org_service.py:290-293`) and + reuses *one* `manage_token` across every project in the loop. If + the projects span stacks, all but one stack receive an invalid + token. +- `data-app password` works with whichever single token is in env at + the moment of the call, regardless of which project alias was + passed. +- For PR #236's bulk invite, the service already enforces a single- + stack invariant (per Padak's verification: "service correctly + enforces a single-stack-URL invariant per CSV (`member_service.py:158-164`)") — + but that's a fail-fast, not a fix; users still need to split CSVs + per stack and rotate env vars manually. + +### Problem 2 — AI exfiltration risk + +The `kbagent` permission firewall (`permissions.py::PermissionEngine`, +wired in `cli.py:319-324`) intercepts CLI ops registered in +`OPERATION_REGISTRY`. It does **not** intercept env-var reads or raw +HTTP from subprocesses. A sandboxed agent under `kbagent init +--read-only` can shell out: + +```bash +curl -H "X-KBC-ManageApiToken: $KBC_MANAGE_API_TOKEN" \ + https://connection.keboola.com/manage/projects +``` + +…and the firewall stays blind. Manage tokens are **org-scoped** +(broader blast radius than the per-project Storage tokens, which are +at least scoped to one project, persisted with `0600`, and gated by +the firewall). Padak's review on #236 framed it precisely: + +> The agent can shell out raw `curl …` and the firewall does not +> catch raw HTTP — it's not a kbagent operation. Read-only sandboxes +> are quietly more permissive than they look once a manage token is +> in env. + +This is technically true even before #236 (`org setup` already exposes +the same surface), but #236 makes manage tokens persist in env *long- +term* because steady-state operations need them. + +## 2. Verified surface (upstream/main @ 10ba4a0) + +| Element | File:line | Note | +|---|---|---| +| `resolve_manage_token()` | `commands/_helpers.py:27-54` | Refactor target. | +| `ENV_KBC_MANAGE_API_TOKEN` | `constants.py:132` | `"KBC_MANAGE_API_TOKEN"`. | +| `ManageClient.__init__(stack_url, manage_token)` | `manage_client.py:17,27-30` | Token consumed once into `headers`; never persisted to instance state. | +| `ManageClient.verify_token()` | `manage_client.py:46-59` | `GET /manage/tokens/verify` already exists; returns `{user: {id, name, email}, ...}`. | +| `OrgService.refresh_tokens` | `services/org_service.py:228-406` | Per-project `_manage_client_factory(stack_url, token)` at lines 290-293 — but the same token is reused across all projects (the bug). | +| `DataScienceClient.get_app_password(app_id, manage_token)` | `data_science_client.py:170-190` | Per-call header pattern. The pattern to **preserve** for any other Manage call site: token never on persistent client headers, always passed per-request. | +| `apply_firewall_flags()` | `cli.py:108-157` | Synthesises session-only `PermissionPolicy`. Does NOT touch `os.environ`. | +| `--deny-writes` / `--deny-destructive` declarations | `cli.py:208-222` | Shape to mirror for a new top-level flag. Stored as `ctx.obj["deny_writes"]`. | +| `OPERATION_REGISTRY` | `permissions.py:15-161` | `".": "read|write|destructive|admin"`. | +| `PermissionPolicy` model | `models.py:36-69` | `{mode: str, allow: list[str], deny: list[str]}`. **No bool fields.** | +| `AppConfig` model | `models.py:71-88` | `version`, `default_project`, `max_parallel_workers`, `permissions`, `projects`. New top-level bool fields land here. | +| `ConfigStore` token storage | `config_store.py:176-226` | Plaintext tokens in JSON; file mode `0o600`; dir `0o700`. | +| `mask_token` utility | `errors.py:97-119` | Use for any new error message that interpolates a token. | +| Plugin sync map | `CONTRIBUTING.md:251-272` | 12 surfaces; walk every row. | + +**Important:** the prior session's draft assumed +`PermissionPolicy.deny_manage_env: bool = False`. That's a category +mismatch — `PermissionPolicy` uses string patterns to match operations +(`cli:write`, `tool:destructive`, …). A boolean about credential +*resolution* doesn't fit. The cleaner shape is on `AppConfig` (e.g. +`AppConfig.allow_env_manage_token: bool = True`). + +## 3. Prior art + +### 3.1 How established CLIs handle multi-region credentials + +| CLI | Where token lives | Disambiguator | Env override semantics | Per-invocation flag | +|---|---|---|---|---| +| **AWS CLI** | `~/.aws/credentials` (plaintext) | profile | env > file (field-level: `AWS_ACCESS_KEY_ID` overrides one field of the profile) | `--profile NAME` | +| **gcloud** | `~/.config/gcloud/...` | configuration | env sets active config (whole-config: `CLOUDSDK_ACTIVE_CONFIG_NAME`) | `--configuration NAME` | +| **kubectl** | `~/.kube/config` (plaintext) | context | env merges multiple files (`KUBECONFIG`, colon-delimited) | `--context NAME`, `--kubeconfig FILE` | +| **gh** (GitHub CLI) | OS keychain (fallback plaintext) | hostname | env replaces stored token entirely (`GH_TOKEN`) | `--hostname HOST` | + +Two dominant patterns: **field-layered override** (AWS) where env +replaces one field of a named profile, and **whole-config switch** +(gcloud, kubectl, gh) where env picks a different complete credential +set. + +Quotations and source URLs in the appendix. + +### 3.2 Keboola-specific prior art + +- **`keboola-as-code`** (the official Go CLI) is project/stack-scoped + per working directory, not profile-based. The stack hostname is + pinned in `.keboola/manifest.json`; the Storage API token in + `.env.local`. **No named-profile system. No multi-stack support. + No manage-token support.** We are designing greenfield. +- **Personal Access Tokens (PATs)** exist in Keboola Account Settings + and work against the Manage API today (`manage_client.py:64-65` + comment confirms: *"Works with Personal Access Tokens (PAT) for + projects where the token owner is a member -- does NOT require + organization admin"*). PATs reduce blast radius vs. an org-admin + token, but PATs are still issued **per stack**. +- **`GET /manage/tokens/verify`** returns + `{id, description, type, scopes, creator, user, ...}` per the + Apiary spec. The response **does not include a stack/org + discriminator at root**, so the client must already know which + stack URL to hit — the endpoint cannot bootstrap stack discovery + from the token alone. **Empirically confirmed in §10.1**: probes + 1A and 1B captured the live response shape on + `connection.europe-west3.gcp.keboola.com` and + `connection.us-east4.gcp.keboola.com`; neither response carries + any stack/org root field. +- **No org-scoped credential primitive that spans stacks exists in + Keboola today.** The multi-stack disambiguation problem is real + and not going away. + +## 4. Five candidate scenarios + +Each candidate answers four questions: + +1. Where does the token live? (env / config.json / OS keychain / hybrid) +2. How does kbagent disambiguate stack? (hostname-derived suffix / + curated alias / named profile / per-project storage) +3. What blocks env-var exfiltration? (opt-in flag / opt-out flag / + persisted policy / child-env scrubbing) +4. How does TTY prompt show up? (per-call / batched / fallback) + +| Scenario | Sketch | +|---|---| +| **S1 — Per-stack env vars + opt-in `--no-env-manage-token`** | Hostname-derived `KBC_MANAGE_TOKEN_` env vars (`EU_CENTRAL_1`, `US_EAST4_GCP`, `NORTH_EUROPE_AZURE`, …). Legacy `KBC_MANAGE_API_TOKEN` falls back. Session-only `--no-env-manage-token` flag mirrors `--deny-writes` and disables both env paths for one invocation. TTY prompt names the stack URL so the human knows which stack the prompt is for. | +| **S2 — Per-project storage in `config.json`** | Manage tokens persisted alongside Storage tokens (0600, plaintext). Multi-stack auto-solved by alias keying. Same hygiene as Storage tokens today. | +| **S3 — OS keychain integration** | macOS Keychain / Linux Secret Service / Windows Credential Manager. Token stored under a stack-keyed item; `keyring` Python library. | +| **S4 — Named profiles à la AWS** | `kbagent --profile prod data-app password ...` bundling `(stack_url, manage_token)` per profile. `KBAGENT_PROFILE` env for shell-level default. | +| **S5 — Refuse env by default; `--allow-env-manage-token` to opt in** | Inverts the threat model. Env vars are ignored unless explicitly allowed. CI pipelines must opt in. | + +## 5. Scoring against the four threats + +The four threats from Padak's review: + +- **T1 — AI agent in `kbagent init --read-only` workspace** tries to + `curl` the Manage API. +- **T2 — CI/CD pipeline rotates a manage token** — how many places + must change? +- **T3 — User has 3 projects across 3 stacks** and runs `project + refresh --all`. +- **T4 — User pastes a stack-A manage token while targeting + stack-B**. Does kbagent fail clearly and immediately? + +Score each cell 0 (catastrophic / breaks the threat scenario) to 3 +(handled cleanly). + +| | T1 (AI exfil) | T2 (CI rotation) | T3 (multi-stack refresh) | T4 (wrong-stack fail-fast) | Total | +|---|---|---|---|---|---| +| **S1: per-stack env + opt-in flag** | 2 | 3 | 3 | 3 | **11** | +| **S2: config.json plaintext** | 1 | 2 | 3 | 3 | 9 | +| **S3: OS keychain** | 3 | 1 | 2 | 3 | 9 | +| **S4: named profiles** | 1 | 2 | 2 | 3 | 8 | +| **S5: refuse env by default** | 3 | 0 | 3 | 3 | 9 | + +T1 dominates the user's threat model (AI-exfil is the explicit +ask). If we weight T1 2× the others: + +| | 2×T1 | T2 | T3 | T4 | Total | +|---|---|---|---|---|---| +| **S1** | 4 | 3 | 3 | 3 | **13** | +| S2 | 2 | 2 | 3 | 3 | 10 | +| S3 | 6 | 1 | 2 | 3 | 12 | +| S5 | 6 | 0 | 3 | 3 | 12 | + +S1 still wins. S3 and S5 are competitive on AI safety but lose on +operational ergonomics (S3 doubles the resolution code to keep CI +working; S5 breaks every existing CI pipeline). + +### Per-cell rationale (S1, the winner) + +- **T1 (2/3)**: The hostname suffix means `KBC_MANAGE_API_TOKEN` + alone no longer works — the user must explicitly set the per-stack + form, which (a) is more friction for casual env exposure, but (b) + **the token is still readable by any subprocess**. Full T1 + protection requires the **persisted policy opt-out** (described + in §6 below) — combining S1 with S5 *behaviour for sandboxed + installs only*. That's the hybrid we recommend. +- **T2 (3/3)**: One env var per stack, set once per pipeline. AWS- + style field-layered. +- **T3 (3/3)**: `OrgService.refresh_tokens` groups projects by + `stack_url` and resolves the per-stack token lazily for each + distinct stack — no env-var swap mid-flow. +- **T4 (3/3)**: When the per-stack env var is set, the suffix + derivation is stack-derived from the URL — so the right token is + picked structurally, not by user attention. If only the legacy + `KBC_MANAGE_API_TOKEN` is set and the target stack doesn't match + what minted it, the resolver still hands the wrong token to + `ManageClient`, which receives a 401. The TTY prompt fallback + names the stack URL explicitly so the human sees the mismatch. + *(Optional future enhancement: call `verify_token()` on first use + per stack and bail out if the user info hints at a mismatch — but + the verify endpoint doesn't expose stack/org metadata today, so + this is best-effort.)* + +### Rejected alternatives (one-liner each) + +- **S2 (config.json plaintext)**: marginal AI-exfil win — the agent + can `cat ~/.config/keboola-agent-cli/config.json` just as easily + as it can read env. Adds at-rest plaintext for no real gain. +- **S3 (OS keychain)**: strongest on T1 in isolation, but CI flows + still need env (no keychain in headless containers), so we'd ship + two parallel resolution paths. Big lift cross-platform; defers + the CI-rotation pain. +- **S4 (named profiles)**: mental-model overhead for casual single- + stack users, who are most of `kbagent`'s audience. AWS/gcloud + bear this cost because their multi-region surface is the *common* + case; for kbagent it's the exception. +- **S5 (refuse env by default)**: breaks every existing CI pipeline + on day one. The right *posture* for sandboxed-agent installs, but + not the right *default*. + +## 6. Recommendation: S1 + S5-for-sandboxed-installs (hybrid) + +### 6.1 Default behavior (S1) + +```python +# commands/_helpers.py — refactored shape +def resolve_manage_token( + stack_url: str | None = None, + *, + allow_env: bool = True, +) -> str: + """Resolve the manage token for the target stack. + + Resolution order: + 1. KBC_MANAGE_TOKEN_ env var (per-stack form) + — only when stack_url is given AND allow_env is True + 2. KBC_MANAGE_API_TOKEN env var (legacy single-stack fallback) + — only when allow_env is True + 3. Interactive TTY prompt — message includes the stack URL so + the human knows which stack the prompt is for + 4. Exit code 2 with an error naming both env-var forms + """ +``` + +Hostname-derived suffix (no curated table — future stacks slot in +automatically): + +| Stack URL | Env var | +|---|---| +| `connection.keboola.com` (legacy AWS US) | `KBC_MANAGE_API_TOKEN` (existing — no per-stack form needed) | +| `connection.eu-central-1.keboola.com` | `KBC_MANAGE_TOKEN_EU_CENTRAL_1` | +| `connection.us-east4.gcp.keboola.com` | `KBC_MANAGE_TOKEN_US_EAST4_GCP` | +| `connection.eu-west1.gcp.keboola.com` | `KBC_MANAGE_TOKEN_EU_WEST1_GCP` | +| `connection.north-europe.azure.keboola.com` | `KBC_MANAGE_TOKEN_NORTH_EUROPE_AZURE` | + +```python +def _stack_suffix_for_env_var(stack_url: str | None) -> str | None: + """Return UPPERCASE suffix derived from the stack hostname. + + The hostname between `connection.` and the trailing `.keboola.com` + becomes the suffix, with non-alphanumerics replaced by underscores. + Returns None for the legacy `connection.keboola.com` (no suffix) + and for empty/None inputs. + """ +``` + +### 6.2 Three knobs for the AI-exfil mitigation + +Three layers of opt-in, smallest blast radius first: + +1. **Session-only flag**: `kbagent --no-env-manage-token ` + — mirrors `--deny-writes` shape (`cli.py:208-222`). Stored as + `ctx.obj["allow_manage_env"]` (negated) and threaded into + `resolve_manage_token(allow_env=...)` at call sites. +2. **Persisted policy (the real AI-exfil fix for sandboxed installs)**: + new top-level field `AppConfig.allow_env_manage_token: bool = True` + (NOT inside `PermissionPolicy` — see §2 note). Set via + `kbagent permissions deny-manage-env`. Once set in a + `kbagent init --read-only` workspace, every subsequent invocation + refuses env-var manage tokens, including from a sandboxed agent + that can't `chmod` the config file to flip it back. +3. **`kbagent init --read-only` default**: when `--read-only` is + passed, automatically set `allow_env_manage_token=False` in the + newly-created `config.json`. This makes the AI-exfil mitigation + the safe default for every new sandboxed install without any + user action. Existing installs are unchanged. + +### 6.3 Call-site updates + +| File:line | Change | +|---|---| +| `commands/data_app.py:594` | Resolve `ProjectConfig.stack_url` from `--project alias` first; pass `stack_url=` and `allow_env=ctx.obj["allow_manage_env"]` to `resolve_manage_token()`. | +| `commands/org.py:223` | Pass `stack_url=url` (the `--url` flag value) and `allow_env=ctx.obj["allow_manage_env"]`. | +| `commands/project.py:423` | Single-project: same pattern as data-app. **Multi-project (`--all`): move the resolution INTO `OrgService.refresh_tokens`**, group projects by `stack_url`, and call `resolve_manage_token` per distinct stack with caching so the user is prompted at most once per stack. | +| `services/org_service.py:228-406` | Accept a `manage_token_resolver` callback (or take the resolver itself) instead of the single `manage_token: str`. Internally group `projects_to_check` by `stack_url`, resolve once per group, store in a local dict for the loop. Adds ~30 LOC; preserves the cleanup-in-finally and per-project error-accumulation invariants. | + +### 6.4 What does NOT change (security invariants preserved) + +- Token never lives on persistent client state (`ManageClient.headers` + consumed once at init; `DataScienceClient` per-call header is the + pattern that scales). +- Token never logged, never echoed in errors (use `mask_token` if + interpolation is unavoidable). +- Token never on argv, never passed via `--manage-token`. +- ConfigStore continues to be the only on-disk persistence layer + for any token (storage tokens), and **manage tokens still don't + land in `config.json`** under this design — the only persisted + bit is the `allow_env_manage_token` boolean policy. + +## 7. Migration + +Zero-cost for existing single-stack users: +- `KBC_MANAGE_API_TOKEN` keeps working (it's the legacy fallback). +- No `config.json` rewrite required. +- `permissions deny-manage-env` is opt-in. + +Multi-stack users export per-stack vars in their CI/CD config. The +docstring on `resolve_manage_token()` and the `gotchas.md` +`(since v0.27.1)` entry are the migration nudge. + +## 8. Versioning, changelog, sync map + +Patch bump: `0.27.0` → `0.27.1`. The change is additive (new flag, +new optional resolver kwargs, new `AppConfig` field with safe +default). Public API of `resolve_manage_token()` evolves — +`stack_url=None` keeps the existing call sites green during PR #236's +rebase if it ships first. + +Walked surfaces (per `CONTRIBUTING.md:251-272`, all 12 rows): + +- `pyproject.toml` (version) +- `src/keboola_agent_cli/changelog.py` (`0.27.1` entry) +- `src/keboola_agent_cli/commands/context.py` (`AGENT_CONTEXT`) +- `CLAUDE.md` (the `## All CLI Commands` block — new + `--no-env-manage-token` global option; new `permissions set + --deny-manage-env` flag) +- `plugins/kbagent/.claude-plugin/plugin.json` (`make version-sync`) +- `plugins/kbagent/.claude-plugin/CLAUDE.md` +- `plugins/kbagent/agents/keboola-expert.md` — Rule 6 VERSION GATE + bump to `0.27.1+`; new matrix row for the env-var family; new + inline gotcha for the AI-exfil concern +- `plugins/kbagent/commands/keboola.md` +- `plugins/kbagent/skills/kbagent/SKILL.md` — description triggers, + workflow link if a `manage-token-workflow.md` is added +- `plugins/kbagent/skills/kbagent/references/commands-reference.md` +- `plugins/kbagent/skills/kbagent/references/gotchas.md` — + `(since v0.27.1)` entry pointing at AI-exfil + multi-stack +- `plugins/kbagent/skills/kbagent/references/permissions-workflow.md` + if it exists, else add a section to the closest analogue + +## 9. Risks / open questions for Padak + +1. **Hostname-derived vs. curated suffix.** Hostname-derived means + `KBC_MANAGE_TOKEN_US_EAST4_GCP` (verbose). Curated means a short + table like `_US`/`_EU`/`_GCP_US` (ambiguous on multiple cloud + regions). Hostname-derived wins on extensibility; curated wins on + typing ergonomics. The brief leans hostname. +2. **Should `--no-env-manage-token` also strip env from MCP + subprocesses?** McpService spawns `keboola-mcp-server` as a + subprocess (`mcp_service.py`). Today subprocess inherits the + parent's env. If we `--no-env-manage-token`, we should also pass + a scrubbed `os.environ` (drop `KBC_MANAGE_API_TOKEN` and any + `KBC_MANAGE_TOKEN_*`) to subprocesses to fully close the + exfil window. This is a small extra refactor on `McpService` that + we'd bundle here unless Padak prefers to defer. +3. **Per-stack `--all` UX.** When `project refresh --all` spans 3 + stacks and 2 of the 3 per-stack env vars are missing, do we (a) + prompt for each missing one in sequence at start of the run, or + (b) fail-fast naming all missing vars? Option (a) is friendlier + for humans, option (b) is better for CI. Recommend (a) when TTY, + (b) otherwise — automatic. +4. **`AppConfig.allow_env_manage_token` as the persisted bool** + versus a tag inside `PermissionPolicy.deny[]` like + `"resolver:env-manage-token"`. The category mismatch argument + says separate field; the precedent argument (everything firewall- + related lives on `PermissionPolicy`) says deny pattern. Padak's + call. + +## 10. Live validation (executed 2026-05-02) + +Two throwaway projects on two GCP stacks, manage tokens scoped to +specific orgs. Tokens never echoed in any output; receipts use +prefix + last-4 mask. Both test projects deleted afterward; orgs +verified back to baseline. + +### 10.1 Read-only probes (Phase 1) + +| # | Probe | HTTP | Empirical finding | +|---|---|---|---| +| 1A | `GET europe-west3.gcp.keboola.com/manage/tokens/verify` (EU token) | 200 | Response body has `id, description, type, scopes, creator, user` — **no `host` / `stackId` / `organizationId` root field**. Confirms the design's assumption: token alone cannot bootstrap stack discovery. | +| 1B | Same against US4 stack with US4 token | 200 | Identical shape. Same email → different `user.id` per stack (179 EU vs 216 US4) — confirms stack-bound user-account model. | +| 1C | EU token → US4 stack `/verify` | **401** | `{"error":"Invalid access token","code":"storage.tokenInvalid"}`. Wrong-stack failure is clean and predictable. | +| 1D | US4 token → EU stack `/verify` | **401** | Same shape. kbagent's `INVALID_TOKEN` mapping → exit 3. | +| 1E | List org 86 (EU) projects | 200 | 4 baseline projects. | +| 1F | List org 3675 (US4) projects | 200 | 2 baseline projects. | + +### 10.2 Multi-stack scenario battery (Phase 3) + +| # | Scenario | Result | +|---|---|---| +| 3.1 | Legacy `KBC_MANAGE_API_TOKEN` only → register EU project | ✅ 200, Storage token minted (`2956-...ivFh`) | +| 3.2 | Both per-stack vars set → register US4 + `refresh --all` | ✅ both projects refreshed across stacks (`2956-...daIl`, `5796-...70AX`) — no 401s | +| 3.3 | Both per-stack vars + bogus `KBC_MANAGE_API_TOKEN` legacy | ✅ per-stack vars correctly preferred (`2956-...UaDx`, `5796-...Xn21`) | +| 3.4 | Wrong-stack token in env (US4 token as EU's per-stack var) | ✅ Per-project failure with masked token in error: `Invalid or expired token (token: 112145-...IF8D)` | +| 3.5 | `--no-env-manage-token` non-TTY with both env vars set | ✅ **Exit 2**, message names lever, no HTTP traffic to `/manage` | +| 3.6 | `--all` resolver caching count | ✅ **`/manage/tokens/verify` called exactly 1× per stack** (`europe-west3`: 1, `us-east4`: 1) — owner-name caching works | +| 3.7 | Persisted `allow_env_manage_token=False` + both env vars set | ✅ **Exit 2** + token never reaches the wire (verified by patching `DataScienceClient` and asserting `not_called`) | +| 3.8 | `kbagent init` (no `--read-only`) writes `allow_env_manage_token=true` | ✅ default-True confirmed in fresh workspace | +| 3.9 | Cross-org isolation | ✅ EU project (2956) → org 86; US4 project (5796) → org 3675; baseline projects in both orgs untouched | + +### 10.3 Cleanup (Phase 4) + +- Both test projects (EU 2956, US4 5796) deleted with `DELETE + /manage/projects/{id}` → HTTP 204. +- Re-list org 86: back to baseline `[1143, 1636, 2043, 2450]`. +- Re-list org 3675: back to baseline `[4066, 4067]`. +- Scratch dir + tokens env file removed; final `grep` confirms no + raw tokens in any persisted artifact. + +### Findings folded into the design + +1. ✅ **Hostname-derived suffix scheme works empirically** — both + `KBC_MANAGE_TOKEN_EUROPE_WEST3_GCP` and `KBC_MANAGE_TOKEN_US_EAST4_GCP` + were correctly derived and resolved. +2. ✅ **Wrong-stack failure mode is HTTP 401 + `storage.tokenInvalid`** — + maps cleanly to kbagent's existing `INVALID_TOKEN` exit-3 path, no + special handling needed. +3. ✅ **Per-stack `_owner_for` caching** behaves as designed: exactly + 1 `/manage/tokens/verify` per stack across an `--all` refresh of + 2 projects on 2 stacks. +4. ✅ **AI-exfil mitigation is empirically airtight**: with + `allow_env_manage_token=False` persisted AND both env vars set in + the shell, the resolver exits 2 BEFORE any HTTP client is even + instantiated. The `DataScienceClient` mock in + `test_persisted_deny_manage_env_blocks_env_resolver_end_to_end` + pins `assert_not_called()`. +5. ⚠️ **Documented limitation**: `--hint client` mode emits a + template Python snippet that reads `os.environ["KBC_MANAGE_API_TOKEN"]` + verbatim, regardless of whether `--no-env-manage-token` is set on + the same invocation. Hint mode is a code-generation aid; the flag + protects the kbagent process, not the user's eventual rendered + script. Pinned by `test_hint_mode_with_no_env_manage_token_documented_limitation`. + +--- + +## Appendix — Prior-art quotations + +### AWS CLI + +> "If you specify an option by using one of the environment variables +> described in this topic, it overrides any value loaded from a +> profile in the configuration file. If you specify an option by +> using a parameter on the AWS CLI command line, it overrides any +> value from either the corresponding environment variable or a +> profile in the configuration file." +> — https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html + +### gcloud + +> "Use this flag on any gcloud command to override the active +> configuration for a single invocation: `gcloud auth list +> --configuration=[CONFIGURATION_NAME]`" +> — https://cloud.google.com/sdk/docs/configurations + +### kubectl + +> "By default, `kubectl` looks for a file named `config` in the +> `$HOME/.kube` directory. You can specify other kubeconfig files +> by setting the `KUBECONFIG` environment variable or by setting +> the `--kubeconfig` flag." +> — https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/ + +### gh + +> "After completion, an authentication token will be stored securely +> in the system credential store. If a credential store is not found +> or there is an issue using it gh will fallback to writing the token +> to a plain text file." +> — https://cli.github.com/manual/gh_auth_login + +### Keboola CLI (`keboola-as-code`) + +> "When you initialize a Keboola CLI project, it creates a metadata +> directory `.keboola`, a manifest file `.keboola/manifest.json`, +> and a file `.env.local` that contains the API token." +> — https://developers.keboola.com/cli/getting-started/ + +### Keboola Manage API token verification + +`GET /manage/tokens/verify` returns `{id, description, type, scopes, +creator, user, ...}` with no stack/org root field per the Apiary +spec. Source: https://github.com/keboola/kbc-manage-api-php-client/blob/master/apiary.apib. +Cross-stack scope is **unverified** in the public docs (not stated +either way for PATs specifically), but every available signal — +separate Apiary endpoints per region, per-stack account-settings UI, +no documented stack-federation primitive — confirms tokens are +stack-bound. diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index cf6d1764..9211ae40 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.27.1", "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..e4f70bc7 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -64,11 +64,13 @@ 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 - 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 - workarounds that use MCP strip-bug-prone tools. + need 0.27.0+, multi-stack manage-token resolution + `--no-env-manage-token` + + `permissions {deny,allow}-manage-env` need 0.27.1+, `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 workarounds that use MCP strip- + bug-prone tools. 7. **ALWAYS USE `--json`**. Every `kbagent` invocation MUST have `--json` as the first flag after `kbagent`. This makes output @@ -104,6 +106,9 @@ a critical failure. | 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) | | 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 | +| Hold manage tokens for projects on multiple stacks (US/EU/GCP/Azure) | `KBC_MANAGE_TOKEN_` env vars (`KBC_MANAGE_TOKEN_EU_CENTRAL_1`, `KBC_MANAGE_TOKEN_US_EAST4_GCP`, `KBC_MANAGE_TOKEN_NORTH_EUROPE_AZURE`, …) (0.27.1+) -- hostname-derived suffix; legacy `KBC_MANAGE_API_TOKEN` is a single-stack fallback | TTY prompt fallback when only some stacks have env vars set (resolve_manage_token names the stack URL in the prompt) | reusing one `KBC_MANAGE_API_TOKEN` across multiple stacks (token is stack-scoped; wrong-stack call returns 401) | +| Refuse env-var manage tokens for ONE invocation | `kbagent --no-env-manage-token ` (0.27.1+) -- session flag; mirrors `--deny-writes` shape | -- | trusting `--deny-writes` to block raw `curl -H "X-KBC-ManageApiToken: $KBC_MANAGE_API_TOKEN" ...` (env vars sit OUTSIDE kbagent's permission firewall) | +| Refuse env-var manage tokens permanently in a sandboxed install | `kbagent permissions deny-manage-env` (0.27.1+) -- persists `allow_env_manage_token=False` in config.json; reversed by `kbagent permissions allow-manage-env`; auto-set by `kbagent init --read-only` (0.27.1+) | `--no-env-manage-token` per call (less robust against AI-agent attempts to flip the policy) | leaving `KBC_MANAGE_API_TOKEN` unrestricted in env when an AI agent has shell access | If the table does not cover the user's task, **ask clarifying questions** instead of guessing. Returning a targeted question is a @@ -236,6 +241,27 @@ success, not a failure. the target project's Encryption API and refuses to write plaintext if the round-trip does not return a `KBC::Project*` ciphertext. +- **Manage tokens are stack-scoped + sit OUTSIDE the firewall** (0.27.1+): + `KBC_MANAGE_API_TOKEN` works only for the stack that minted it. + Projects spanning `connection.eu-central-1.keboola.com` / + `connection.us-east4.gcp.keboola.com` / Azure stacks need per-stack + env vars: `KBC_MANAGE_TOKEN_` (`_EU_CENTRAL_1`, + `_US_EAST4_GCP`, `_NORTH_EUROPE_AZURE`, …). Hostname-derived; no + curated table. Legacy single-var keeps working as a fallback for + single-stack callers. **AI-exfil mitigation**: env vars are NOT + guarded by `kbagent`'s permission firewall — a sandboxed agent + with `KBC_MANAGE_API_TOKEN` in its env can `curl -H "X-KBC- + ManageApiToken: $KBC_MANAGE_API_TOKEN" /manage/projects` while + `--deny-writes` silently lets it through. Three layers of opt-out: + `--no-env-manage-token` (session, like `--deny-writes`), + `kbagent permissions deny-manage-env` (persisted; gated by random- + code confirmation; survives across invocations and config reloads), + and `kbagent init --read-only` (auto-sets `allow_env_manage_token= + False` for new sandboxed installs). When the deny is active, + `resolve_manage_token` falls through to a TTY prompt that names the + target stack URL — non-interactive callers get exit code 2 and a + message naming both env-var forms. + - **`storage bucket-detail` is dialect-aware** (0.25.3+): the response shape depends on the bucket's backend. Snowflake buckets carry `snowflake_database` / `snowflake_schema` and per-table diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 39bbda12..ae26b5c4 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -30,6 +30,13 @@ description: > data-app create, data-app deploy, data-app password, data-app start, app proxy, simpleAuth, app auto-suspend, configVersion, redeploy contract, Data Science API, /apps endpoint, app password, KBC::Project ciphertext, + manage token, manage api token, KBC_MANAGE_API_TOKEN, + KBC_MANAGE_TOKEN_, per-stack manage token, multi-stack manage token, + multi-stack token resolution, manage token across stacks, + no-env-manage-token, --no-env-manage-token, + permissions deny-manage-env, permissions allow-manage-env, + AI exfiltration, exfiltrate manage token, env-var manage token, + read-only workspace, kbagent init --read-only, sandboxed agent token, local workspace, project directory, kbagent init. --- @@ -81,6 +88,8 @@ When working inside a git repository or project directory, run `kbagent init` (o | Show the current active permission policy | `kbagent permissions show` | | Set the permission policy (firewall rules) | `kbagent permissions set --mode MODE` | | Remove all permission restrictions | `kbagent permissions reset` | +| Refuse to read manage tokens from environment variables | `kbagent permissions deny-manage-env` | +| Re-allow reading manage tokens from environment variables (default) | `kbagent permissions allow-manage-env` | | Check if a specific operation is allowed | `kbagent permissions check ` | | Add a new Keboola project connection | `kbagent project add --project ALIAS` | | List all connected Keboola projects | `kbagent project list` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 2976b66c..b4bf7625 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -24,7 +24,17 @@ 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 +- `--no-env-manage-token` (since v0.27.1) -- refuse to read manage tokens from env vars (`KBC_MANAGE_TOKEN_` and `KBC_MANAGE_API_TOKEN`) for this invocation. TTY prompt only. Use inside AI-agent sandboxes -- env vars sit OUTSIDE the firewall, so a subprocess can `curl /manage/...` even when `--deny-writes` is set +- All three flags compose: `kbagent --deny-writes --deny-destructive --no-env-manage-token ...` is the strictest one-shot run + +## Permission persistence (config.json) +- `permissions list [--category read|write|destructive|admin]` -- list every operation the firewall knows about + current allow/deny status under the active policy +- `permissions show` -- print the currently active permission policy (persisted + session flags) +- `permissions set --mode allow|deny [--allow PATTERN ...] [--deny PATTERN ...]` -- replace the persisted firewall policy. Patterns: exact (`branch.delete`), glob (`tool:create_*`), category (`cli:write`, `tool:read`). Gated by random-code interactive confirmation +- `permissions reset` -- remove the persisted firewall policy (does NOT touch the manage-env policy below). Gated by random-code interactive confirmation +- `permissions deny-manage-env` (since v0.27.1) -- persist `allow_env_manage_token=False` so future invocations refuse env-var manage tokens regardless of session flags. Gated by random-code interactive confirmation. Ideal inside `kbagent init --read-only` workspaces +- `permissions allow-manage-env` (since v0.27.1) -- revert to default-allow for env-var manage tokens. Gated by random-code interactive confirmation +- `permissions check OPERATION` -- exit 0 if the operation is allowed under the active policy, exit 6 otherwise ## 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`) @@ -167,7 +177,7 @@ Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, - `encrypt values --project ALIAS --component-id ID --input JSON|@file|- [--output-file PATH]` -- encrypt #-prefixed secrets via Keboola Encryption API (one-way, no decrypt). Scope: ComponentSecure (project + component). Use for MCP tool call workflows. ## Utility -- `init [--from-global]` -- create local `.kbagent/` workspace (per-directory isolation) +- `init [--from-global] [--read-only]` -- create local `.kbagent/` workspace (per-directory isolation). `--read-only` (since v0.25.x) sets a deny-writes firewall AND defaults `allow_env_manage_token=False` (since v0.27.1) for AI-agent sandboxes - `doctor [--fix]` -- health checks; `--fix` auto-installs MCP server binary - `version` -- show version and check for MCP server updates - `context` -- full usage instructions for AI agents @@ -180,13 +190,17 @@ Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, | `--no-color` | Disable colors | | `--config-dir` | Override config directory | | `--hint client\|service` | Generate Python code instead of executing (see [programming-with-cli.md](programming-with-cli.md)) | +| `--deny-writes` | Session firewall: block writes/destructive/admin | +| `--deny-destructive` | Session firewall: block destructive only | +| `--no-env-manage-token` (since v0.27.1) | Refuse env-var manage tokens for this invocation; TTY prompt only | ## Environment Variables | Variable | Purpose | |----------|---------| | `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) -- legacy single-stack form; works for any stack pre-0.27.1 and as a fallback after | +| `KBC_MANAGE_TOKEN_` (since v0.27.1) | Per-stack Manage API token. Suffix is the hostname segment between `connection.` and `.keboola.com`, uppercased, non-alnum -> `_`. Examples: `KBC_MANAGE_TOKEN_EU_CENTRAL_1`, `KBC_MANAGE_TOKEN_US_EAST4_GCP`, `KBC_MANAGE_TOKEN_NORTH_EUROPE_AZURE`. The legacy stack `connection.keboola.com` has no per-stack form -- use `KBC_MANAGE_API_TOKEN` | | `KBAGENT_CONFIG_DIR` | Override config directory | ## Exit Codes diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 82321578..9ef8358a 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -911,3 +911,62 @@ The trade-off is deliberate: one big call avoids the O(unique-parents) round-tri - To inspect or remove schedules: `kbagent flow schedule-remove` deletes all scheduler configs that target the flow. Pair it with `--dry-run` to see the affected configs (cron + timezone) without calling `delete_config`. + +## Manage tokens are stack-scoped + sit OUTSIDE the firewall (since v0.27.1) + +`KBC_MANAGE_API_TOKEN` works only for the stack that minted it. +A `kbagent` install that holds projects on multiple stacks (US, EU, +GCP, Azure) cannot use a single env var; the wrong stack returns 401 +silently. Two related fixes shipped in 0.27.1: + +**Per-stack env vars.** `resolve_manage_token()` derives the +expected env-var name from the target stack URL and looks it up +first: + +| Stack URL | Expected env var | +|---|---| +| `connection.keboola.com` (legacy AWS US) | `KBC_MANAGE_API_TOKEN` (no per-stack form) | +| `connection.eu-central-1.keboola.com` | `KBC_MANAGE_TOKEN_EU_CENTRAL_1` | +| `connection.us-east4.gcp.keboola.com` | `KBC_MANAGE_TOKEN_US_EAST4_GCP` | +| `connection.eu-west1.gcp.keboola.com` | `KBC_MANAGE_TOKEN_EU_WEST1_GCP` | +| `connection.north-europe.azure.keboola.com` | `KBC_MANAGE_TOKEN_NORTH_EUROPE_AZURE` | + +The suffix is the hostname between `connection.` and `.keboola.com`, +uppercased with non-alphanumerics replaced by underscores — no +curated table to maintain, future stacks slot in automatically. The +legacy `KBC_MANAGE_API_TOKEN` is the fallback when no per-stack var +is set, so single-stack users keep working unchanged. `OrgService. +refresh_tokens` also got a per-stack resolver path: `kbagent project +refresh --all` groups projects by `stack_url` and prompts/resolves +once per distinct stack. + +**AI-exfiltration mitigation.** Env vars are NOT guarded by +`kbagent`'s permission firewall — `--deny-writes` blocks +`OPERATION_REGISTRY` ops but a sandboxed agent can still `curl -H +"X-KBC-ManageApiToken: $KBC_MANAGE_API_TOKEN" /manage/projects` and +the firewall stays blind because raw HTTP is not a kbagent +operation. Manage tokens are org-scoped, broader blast radius than +per-project Storage tokens. Three layers of opt-out: + +1. `kbagent --no-env-manage-token ` — session-only flag. + Mirrors `--deny-writes`. `resolve_manage_token` refuses both env- + var forms; falls through to TTY prompt or exit 2. +2. `kbagent permissions deny-manage-env` — persisted policy + (`AppConfig.allow_env_manage_token=False`). Survives across + invocations; gated by the same random-code interactive + confirmation that `permissions set` and `permissions reset` use, + so an AI agent cannot flip it back programmatically. Reverse with + `kbagent permissions allow-manage-env`. +3. `kbagent init --read-only` — defaults the persisted policy to + `False` for new sandboxed installs. Operators who need env-var + tokens in a specific read-only workspace must `permissions + allow-manage-env` explicitly. + +**For LLM/agent callers**: when `permissions show` reports +`allow_env_manage_token: False`, do NOT attempt to set +`KBC_MANAGE_API_TOKEN` in the agent's own subprocess env and call +the Manage API — the resolver refuses it and the policy is the +operator's signal that env-var manage tokens should not be used. +Ask the parent agent to pipe a token through TTY for one +invocation, or to `permissions allow-manage-env` if env-var +resolution is genuinely needed. diff --git a/plugins/kbagent/skills/kbagent/references/permissions-workflow.md b/plugins/kbagent/skills/kbagent/references/permissions-workflow.md index 70763fab..30658932 100644 --- a/plugins/kbagent/skills/kbagent/references/permissions-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/permissions-workflow.md @@ -192,10 +192,61 @@ kbagent permissions reset # type confirmation code # optionally remove .claude/settings.json deny rules ``` +## Manage tokens and the firewall (since v0.27.1) + +The firewall guards `OPERATION_REGISTRY` calls -- it does NOT +intercept env-var reads or raw HTTP. So a sandboxed agent with +`KBC_MANAGE_API_TOKEN` (or `KBC_MANAGE_TOKEN_`) in its env +can `curl -H "X-KBC-ManageApiToken: $KBC_MANAGE_API_TOKEN" +https://connection.keboola.com/manage/projects` and the firewall +stays blind. Manage tokens are org-scoped (broader blast radius +than per-project Storage tokens), so this leak matters. + +`kbagent` ships three layers of opt-out: + +```bash +# 1. Session-only flag (one invocation): +kbagent --no-env-manage-token data-app password --project foo --app-id 42 +# resolve_manage_token refuses both env-var forms and falls through +# to TTY prompt (or exit 2 in non-interactive mode). + +# 2. Persisted policy (survives across invocations): +kbagent permissions deny-manage-env # type random confirmation code +# AppConfig.allow_env_manage_token = False +# Reverse with: +kbagent permissions allow-manage-env + +# 3. Auto-set for AI sandboxes: +kbagent init --from-global --read-only +# In addition to the firewall, this sets +# allow_env_manage_token=False for the new workspace. +``` + +When the persisted policy denies env, the resolver behaves the same +as if `--no-env-manage-token` were passed on every invocation: env +vars are ignored; TTY prompt is the only path; non-interactive exits +2 with a message naming both env-var forms. The same random-code +confirmation prompt that gates `permissions set` and `reset` gates +`permissions {deny,allow}-manage-env` -- an AI agent cannot flip +the policy programmatically. + +**Stack-aware resolver**: `resolve_manage_token()` also gained per- +stack env-var lookup in v0.27.1. The hostname-derived form +`KBC_MANAGE_TOKEN_` (e.g. +`KBC_MANAGE_TOKEN_EU_CENTRAL_1` for `connection.eu-central-1.keboola.com`) +is preferred when the caller can supply a `stack_url`. The legacy +single-var `KBC_MANAGE_API_TOKEN` is the fallback for callers that +cannot. Multi-stack `kbagent project refresh --all` resolves once +per distinct stack with internal caching, so prompts fire at most +once per stack. See `gotchas.md` ("Manage tokens are stack-scoped + +sit OUTSIDE the firewall") for the full table of stack-to-suffix +mappings. + ## Key details - **Exit code 6** = operation blocked by permission policy - **`permissions` commands always work** -- you can never lock yourself out of checking/listing - **Changing or removing the policy requires interactive confirmation** (random code typed by human) - **New commands not in the registry** are treated as write operations (fail-closed) -- Policy is stored in `config.json` alongside project configs +- Policy is stored in `config.json` alongside project configs (mode 0600) +- **`permissions reset` does NOT touch `allow_env_manage_token`** -- the manage-env policy is intentionally a separate axis. Use `permissions allow-manage-env` to revert it diff --git a/pyproject.toml b/pyproject.toml index 8884aef6..33eb24f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.27.0" +version = "0.27.1" 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..bc4a4a76 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,16 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.27.1": [ + "Hardened: `resolve_manage_token()` is now stack-aware and gains an AI-exfiltration kill switch. Manage tokens are stack-scoped (`connection.eu-central-1.keboola.com` ≠ `connection.us-east4.gcp.keboola.com` ≠ Azure stacks); the single `KBC_MANAGE_API_TOKEN` env var only worked for the stack that minted the token, so projects spanning multiple stacks in `config.json` silently received 401s on the wrong-stack branches. New per-stack form `KBC_MANAGE_TOKEN_` (`KBC_MANAGE_TOKEN_EU_CENTRAL_1`, `KBC_MANAGE_TOKEN_US_EAST4_GCP`, `KBC_MANAGE_TOKEN_NORTH_EUROPE_AZURE`, …) — hostname-derived, deterministic, no curated table to maintain. Legacy `KBC_MANAGE_API_TOKEN` keeps working as a single-stack fallback (zero migration cost).", + "New: top-level `--no-env-manage-token` session flag mirrors `--deny-writes` shape. When set, `resolve_manage_token` refuses both env-var forms — TTY prompt only — closing the env-var exfiltration window for AI-agent sandboxes for one invocation.", + "New: persisted `AppConfig.allow_env_manage_token: bool = True` field, set via two new CLI commands `kbagent permissions deny-manage-env` and `kbagent permissions allow-manage-env` (both gated by the existing random-code interactive confirmation). Once denied, every kbagent invocation refuses env-var manage tokens regardless of session flag — useful inside `kbagent init --read-only` workspaces (where the AI agent cannot `chmod` config.json back).", + 'New: `kbagent init --read-only` now defaults `allow_env_manage_token=False`. Read-only workspaces are typically AI-agent sandboxes; env vars sit outside the kbagent permission firewall, so a sandboxed agent could `curl -H "X-KBC-ManageApiToken: $KBC_MANAGE_API_TOKEN" /manage/projects` while `--deny-writes` silently let it through. Defaulting deny closes that window automatically; operators can `permissions allow-manage-env` to opt out.', + "Fix: `OrgService.refresh_tokens` no longer reuses a single manage token across projects on different stacks. New optional `manage_token_resolver: Callable[[str], str]` keyword argument groups projects by `stack_url` and lazily resolves at most once per distinct stack with internal caching. The legacy `manage_token: str` parameter is preserved for single-stack callers; mutually exclusive with the resolver. `kbagent project refresh --all` now passes a resolver from the command layer so multi-stack configs Just Work — a TTY prompt fires once per stack that lacks the per-stack env var.", + "Refactored: three command call sites (`commands/org.py:setup`, `commands/project.py:refresh`, `commands/data_app.py:password`) now thread `stack_url=` and `allow_env=` to the resolver. `data-app password` resolves the project's `stack_url` from `--project alias` up front so the per-stack env var is picked correctly and the TTY prompt names the right stack.", + "Tests: 26+ new test cases across 6 classes covering the resolver, multi-stack service refactor, persisted-policy round-trip, the new permissions subcommands, and end-to-end resolver-blocks-env behavior. `TestStackSuffixForEnvVar` (suffix derivation across 5 canonical stacks + 7 malformed/non-Keboola edge cases + uppercase/underscore handling + path-ignored + the documented hyphen-vs-underscore collision). `TestResolveManageToken` (resolver order: per-stack hit, legacy fallback, `allow_env=False` skips both, TTY prompt names stack URL, error message names both env-var forms, `allow_env=False` error message names the lever). `TestRefreshTokensMultiStack` (resolver invoked once per distinct stack with caching, legacy single-token path preserved, mutually-exclusive parameter validation, `typer.Exit` from lazy resolver propagates rather than being swallowed as per-project failure). `TestAppConfig` (round-trip + legacy-config load with default-True). `TestInit` (`--read-only` defaults to deny; plain `init` keeps default-true; persisted-deny end-to-end with `DataScienceClient.assert_not_called()`; `--hint client --no-env-manage-token` documented limitation). `TestPermissionsManageEnv` (`deny`/`allow` subcommand happy paths, random-code rejection paths, and `permissions reset` does NOT touch the manage-env axis). `TestE2EPermissionsManageEnv` (3 e2e tests gated on `E2E_API_TOKEN` per CLAUDE.md convention #16).", + "Plugin: new `(since v0.27.1)` gotcha in `gotchas.md` covering both the multi-stack ambiguity and the AI-exfiltration risk; new inline gotcha row in `keboola-expert.md`; new `--no-env-manage-token` and `permissions {deny,allow}-manage-env` rows in the keboola-expert tool-selection matrix; Rule 6 VERSION GATE example bumped to `0.27.1+`. New `## Manage tokens and the firewall` section in `permissions-workflow.md` with worked examples. Resolves Padak's review on PR #236; corresponding RFC design doc lives at `docs/manage-token-design.md`.", + ], "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..55ac5083 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -220,6 +220,17 @@ def main( "branch delete, etc.). Admin ops like 'project remove' and 'org setup' " "are NOT blocked -- use --deny-writes for the wide net.", ), + no_env_manage_token: bool = typer.Option( + False, + "--no-env-manage-token", + help="Session-only: refuse to read manage tokens from environment " + "variables (KBC_MANAGE_TOKEN_ and KBC_MANAGE_API_TOKEN). " + "TTY prompt only. Use inside AI-agent sandboxes where env is " + "outside the kbagent permission firewall and any subprocess can " + "exfiltrate the token via raw HTTP. The persisted equivalent is " + "`kbagent permissions deny-manage-env` (also auto-set by " + "`kbagent init --read-only`).", + ), ) -> None: """Global options applied to all commands.""" from .auto_update import maybe_auto_update, show_post_update_changelog @@ -312,9 +323,19 @@ def main( try: config = config_store.load() persisted_policy = config.permissions + persisted_allow_env_manage_token = config.allow_env_manage_token except Exception: - # Config may be invalid (e.g. corrupted JSON) -- skip persisted policy + # Config may be invalid (e.g. corrupted JSON) -- skip persisted policy. persisted_policy = None + # Fail closed for the manage-env policy when a config file exists + # but cannot be loaded: a sandboxed install might have persisted + # `allow_env_manage_token=False` and a corruption must NOT silently + # re-enable env-var manage-token reads. When no config file exists + # at all (truly fresh install), the safe default is True so existing + # CI workflows keep working. + config_path = (resolved_dir / "config.json") if resolved_dir else None + config_exists_but_unreadable = bool(config_path and config_path.exists()) + persisted_allow_env_manage_token = not config_exists_but_unreadable session_policy = apply_firewall_flags( persisted_policy, @@ -323,6 +344,11 @@ def main( ) permission_engine = PermissionEngine(session_policy) + # AI-exfiltration mitigation: env-var manage tokens are allowed only when + # BOTH the persisted policy permits it AND the session flag is not set. + # Either gate flips the resolver into TTY-only mode for this invocation. + allow_manage_env = persisted_allow_env_manage_token and not no_env_manage_token + # Resolve hint mode hint_mode = None if hint: @@ -339,6 +365,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_manage_env"] = allow_manage_env 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..7f09a4b5 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -9,48 +9,144 @@ """ import os +import re import sys from typing import Any +from urllib.parse import urlparse import typer from ..config_store import ConfigStore from ..constants import ( ENV_KBC_MANAGE_API_TOKEN, + ENV_KBC_MANAGE_TOKEN_PREFIX, EXIT_JOB_TIMEOUT_TERMINATED, EXIT_PERMISSION_DENIED, ) from ..errors import ErrorCode, KeboolaApiError, PermissionDeniedError from ..output import OutputFormatter +_LEGACY_HOSTNAME = "connection.keboola.com" +_NON_ALNUM = re.compile(r"[^A-Za-z0-9]+") -def resolve_manage_token() -> str: - """Resolve the manage token from env var or interactive prompt. - 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 +def _stack_suffix_for_env_var(stack_url: str | None) -> str | None: + """Derive the per-stack env-var suffix from a Keboola stack URL. + + Strategy: hostname-derived, deterministic, no curated table. The + hostname between ``connection.`` and the trailing ``.keboola.com`` + becomes the suffix, uppercased with non-alphanumerics replaced by + underscores. Future stacks slot in automatically. + + Returns ``None`` for the legacy single-stack case + (``connection.keboola.com``) and for empty / malformed input — those + callers fall back to the legacy ``KBC_MANAGE_API_TOKEN`` env var. + + Examples: + https://connection.keboola.com -> None + https://connection.eu-central-1.keboola.com -> "EU_CENTRAL_1" + https://connection.us-east4.gcp.keboola.com -> "US_EAST4_GCP" + https://connection.north-europe.azure.keboola.com -> "NORTH_EUROPE_AZURE" + """ + if not stack_url: + return None + try: + host = urlparse(stack_url).hostname or "" + except ValueError: + return None + host = host.lower() + if not host or host == _LEGACY_HOSTNAME: + return None + if not host.startswith("connection.") or not host.endswith(".keboola.com"): + return None + middle = host[len("connection.") : -len(".keboola.com")] + if not middle: + return None + suffix = _NON_ALNUM.sub("_", middle).strip("_").upper() + return suffix or None + + +def resolve_manage_token( + stack_url: str | None = None, + *, + allow_env: bool = True, +) -> str: + """Resolve the manage token for the target stack. + + Resolution order: + 1. ``KBC_MANAGE_TOKEN_`` env var, when ``stack_url`` is + given AND ``allow_env`` is True. The suffix is derived from + the stack hostname (see :func:`_stack_suffix_for_env_var`). + 2. ``KBC_MANAGE_API_TOKEN`` env var, when ``allow_env`` is True + — legacy single-stack fallback so existing CI keeps working. + 3. Interactive TTY prompt (hidden input). The prompt names the + stack URL so the operator knows which stack to type for. + 4. Exit code 2 with an error naming both env-var forms. + + Manage tokens are stack-scoped (`connection.eu-central-1.keboola.com` + != `connection.us-east4.gcp.keboola.com`); the per-stack form lets a + single shell hold tokens for projects on multiple stacks without + swapping vars. ``allow_env=False`` is the AI-exfiltration kill + switch — set by the top-level ``--no-env-manage-token`` flag or by + the persisted ``AppConfig.allow_env_manage_token=False`` policy + (typically active inside ``kbagent init --read-only`` workspaces). + + Args: + stack_url: Target stack URL. When provided, enables per-stack + env-var lookup. ``None`` (legacy callers) falls straight to + the single-stack env var or TTY. + allow_env: When False, both env-var paths are skipped — the + resolver behaves as if no env var were set, forcing TTY + prompt or exit 2. The token never reaches a Manage API call + from env in this mode. Returns: - The manage API token. + The manage API token, never logged or echoed. Raises: typer.Exit: If no token can be resolved. """ - env_token = os.environ.get(ENV_KBC_MANAGE_API_TOKEN) - if env_token: - return env_token + if allow_env: + suffix = _stack_suffix_for_env_var(stack_url) + if suffix: + per_stack_env = f"{ENV_KBC_MANAGE_TOKEN_PREFIX}{suffix}" + env_token = os.environ.get(per_stack_env) + if env_token: + return env_token + + env_token = os.environ.get(ENV_KBC_MANAGE_API_TOKEN) + if env_token: + return env_token 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.", - err=True, - ) + prompt_label = f"Manage API token for {stack_url}" if stack_url else "Manage API token" + return typer.prompt(prompt_label, hide_input=True) + + suffix = _stack_suffix_for_env_var(stack_url) + expected_var = f"{ENV_KBC_MANAGE_TOKEN_PREFIX}{suffix}" if suffix else ENV_KBC_MANAGE_API_TOKEN + if allow_env: + # Avoid printing the legacy fallback name twice when no per-stack + # form is derivable from the URL — that just reads as a typo to + # the operator. + if expected_var == ENV_KBC_MANAGE_API_TOKEN: + hint = f"Set {expected_var}" + else: + hint = f"Set {expected_var} (or {ENV_KBC_MANAGE_API_TOKEN} as a fallback)" + msg = f"Error: No manage token available. {hint} or run interactively." + else: + # Env reads are disabled; only TTY would have unblocked this call. + # Name BOTH levers so the operator knows which to flip if they + # actually intended env-var resolution to work. + msg = ( + "Error: No manage token available. Env-var manage tokens are " + "disabled in this session (--no-env-manage-token, or persisted " + "via `kbagent permissions deny-manage-env` / `kbagent init " + "--read-only`); run interactively to provide one, or run " + "`kbagent permissions allow-manage-env` to re-enable env-var " + "resolution." + ) + typer.echo(msg, 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..63255f55 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -50,6 +50,7 @@ --hint MODE Generate Python code instead of executing (MODE: client or service) --deny-writes Session-only firewall: block the WIDE NET -- every write, destructive, AND admin op (project add/remove/edit, org setup, all storage mutations) --deny-destructive Session-only firewall: NARROW -- block only data-destructive ops in Keboola (delete-table/bucket/column, terminate-job, branch delete). Admin ops (project remove, org setup) stay allowed -- use --deny-writes for those + --no-env-manage-token Session-only: refuse manage tokens from env vars (KBC_MANAGE_TOKEN_ and KBC_MANAGE_API_TOKEN). TTY prompt only. AI-exfil mitigation. (since v0.27.1) ## All Commands @@ -683,6 +684,13 @@ kbagent permissions reset Remove all restrictions. + kbagent permissions deny-manage-env + Persisted: refuse env-var manage tokens (KBC_MANAGE_TOKEN_, KBC_MANAGE_API_TOKEN). TTY prompt only. + Closes the AI-exfiltration window inside `kbagent init --read-only` workspaces (since v0.27.1). + + kbagent permissions allow-manage-env + Re-allow env-var manage tokens (default). Reverts `permissions deny-manage-env` (since v0.27.1). + kbagent permissions check OPERATION Check if operation is allowed. Exit 0=allowed, 6=denied. diff --git a/src/keboola_agent_cli/commands/data_app.py b/src/keboola_agent_cli/commands/data_app.py index aeff0d0f..0c866f73 100644 --- a/src/keboola_agent_cli/commands/data_app.py +++ b/src/keboola_agent_cli/commands/data_app.py @@ -583,15 +583,35 @@ 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 a Manage API token in addition to the project's Storage + token. The Manage token is read from `KBC_MANAGE_TOKEN_` + (per-stack, since v0.27.1; the suffix is derived from the project's + stack hostname) or `KBC_MANAGE_API_TOKEN` (legacy single-stack + fallback), or via interactive hidden prompt; never persisted. """ 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() + + # Resolve the project's stack URL up front so resolve_manage_token can + # pick the per-stack env var (KBC_MANAGE_TOKEN_) and the TTY + # prompt names the right stack. If the alias is missing we'd hit the + # service's own error path; resolving early gives a cleaner exit. + config_store = ctx.obj["config_store"] + cfg = config_store.load() + target = cfg.projects.get(project) + if target is None: + formatter.error( + message=f"Project '{project}' not found in config", + error_code=ErrorCode.CONFIG_ERROR, + ) + raise typer.Exit(code=5) + + manage_token = resolve_manage_token( + stack_url=target.stack_url, allow_env=ctx.obj["allow_manage_env"] + ) try: result = service.get_data_app_password( diff --git a/src/keboola_agent_cli/commands/init.py b/src/keboola_agent_cli/commands/init.py index 172a68b0..2226ae61 100644 --- a/src/keboola_agent_cli/commands/init.py +++ b/src/keboola_agent_cli/commands/init.py @@ -101,6 +101,16 @@ def init_command( mode="allow", deny=["cli:write", "tool:write"], ) + # Read-only workspaces are typically AI-agent sandboxes. Env vars + # sit OUTSIDE the kbagent permission firewall, so a sandboxed + # agent with KBC_MANAGE_API_TOKEN in its env can `curl` the + # Manage API directly while --deny-writes silently lets it + # through. Default to refusing env-var manage tokens to close + # that exfiltration window; the operator can re-enable later + # via `kbagent permissions allow-manage-env` if they need to + # (likewise, `kbagent permissions deny-manage-env` is the same + # lever for an existing non-read-only workspace). + config.allow_env_manage_token = False local_store = ConfigStore(config_dir=local_dir, source="local") local_store.save(config) @@ -172,6 +182,15 @@ def _create_claude_settings(project_dir: Path, kbagent_dir: Path) -> None: "Bash(kbagent permissions reset*)", "Bash(*permissions set*)", "Bash(*permissions reset*)", + # Defense-in-depth parity for the manage-env policy commands + # (since v0.27.1). The random-code TTY confirmation is the + # primary gate; these deny rules add a second layer at the + # Claude Code permission boundary so the agent never even + # attempts to invoke them programmatically. + "Bash(kbagent permissions deny-manage-env*)", + "Bash(kbagent permissions allow-manage-env*)", + "Bash(*permissions deny-manage-env*)", + "Bash(*permissions allow-manage-env*)", "Bash(*--config-dir*)", "Bash(*KBAGENT_CONFIG_DIR*)", ] diff --git a/src/keboola_agent_cli/commands/org.py b/src/keboola_agent_cli/commands/org.py index 25b95638..3ae345e1 100644 --- a/src/keboola_agent_cli/commands/org.py +++ b/src/keboola_agent_cli/commands/org.py @@ -202,8 +202,11 @@ 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 token is read from `KBC_MANAGE_TOKEN_` (per-stack, + since v0.27.1) or `KBC_MANAGE_API_TOKEN` (legacy single-stack + fallback) env var, or prompted interactively (never passed as a CLI + argument for security). See `kbagent context` or the plugin + `commands-reference.md` for the per-stack suffix table. """ if should_hint(ctx): emit_hint(ctx, "org.setup", org_id=org_id, url=url, dry_run=dry_run) @@ -220,7 +223,7 @@ def org_setup( ) raise typer.Exit(code=2) - manage_token = resolve_manage_token() + manage_token = resolve_manage_token(stack_url=url, allow_env=ctx.obj["allow_manage_env"]) # Build kwargs shared by preview and real call setup_kwargs: dict = { diff --git a/src/keboola_agent_cli/commands/permissions.py b/src/keboola_agent_cli/commands/permissions.py index 946a6af6..67ab241f 100644 --- a/src/keboola_agent_cli/commands/permissions.py +++ b/src/keboola_agent_cli/commands/permissions.py @@ -161,21 +161,33 @@ def permissions_show( deny_writes = bool(ctx.obj.get("deny_writes")) if ctx.obj else False deny_destructive = bool(ctx.obj.get("deny_destructive")) if ctx.obj else False + # Session-only AI-exfil flag. The persisted equivalent lives on + # AppConfig.allow_env_manage_token and is reported separately below. + # The session flag is meaningful only when the persisted policy still + # allows env-var reads — otherwise the persisted denial is what's + # active and the session flag adds nothing. + no_env_manage_token = ( + ctx.obj.get("allow_manage_env") is False if ctx.obj else False + ) and config.allow_env_manage_token session_flags: list[str] = [] if deny_writes: session_flags.append("--deny-writes") if deny_destructive: session_flags.append("--deny-destructive") + if no_env_manage_token: + session_flags.append("--no-env-manage-token") persisted = config.permissions + persisted_manage_env_denied = not config.allow_env_manage_token - if persisted is None and not session_flags: + if persisted is None and not session_flags and not persisted_manage_env_denied: if formatter.json_mode: formatter.output( { "active": False, "message": "No permission policy configured", "session_flags": [], + "allow_env_manage_token": config.allow_env_manage_token, } ) else: @@ -183,7 +195,7 @@ def permissions_show( return policy_data: dict[str, Any] = { - "active": persisted is not None or bool(session_flags), + "active": (persisted is not None or bool(session_flags) or persisted_manage_env_denied), "persisted": ( None if persisted is None @@ -194,6 +206,7 @@ def permissions_show( } ), "session_flags": session_flags, + "allow_env_manage_token": config.allow_env_manage_token, } # Keep legacy top-level keys when a persisted policy exists so existing @@ -229,6 +242,13 @@ def permissions_show( "[dim](active for this invocation only; not persisted)[/dim]" ) + if persisted_manage_env_denied: + formatter.console.print( + "[bold red]Env-var manage tokens DENIED[/bold red] " + "[dim](resolve_manage_token refuses KBC_MANAGE_API_TOKEN and " + "KBC_MANAGE_TOKEN_; TTY prompt only)[/dim]" + ) + @permissions_app.command("set") def permissions_set( @@ -339,6 +359,9 @@ def permissions_reset( config = config_store.load() config.permissions = None + # `reset` clears the firewall but preserves the manage-env policy -- + # users who set --deny-manage-env should not have it silently undone + # by `reset`. Use `permissions allow-manage-env` to explicitly re-enable. config_store.save(config) if formatter.json_mode: @@ -349,6 +372,96 @@ def permissions_reset( ) +@permissions_app.command("deny-manage-env") +def permissions_deny_manage_env( + ctx: typer.Context, +) -> None: + """Refuse to read manage tokens from environment variables. + + Persists ``allow_env_manage_token=False`` in config.json. Once set, + every kbagent invocation refuses ``KBC_MANAGE_API_TOKEN`` and + ``KBC_MANAGE_TOKEN_`` -- a TTY prompt is the only path. Use + inside ``kbagent init --read-only`` workspaces (where the AI agent + cannot ``chmod`` the config back) to close the env-var exfiltration + window: env vars sit outside kbagent's permission firewall, so any + subprocess can ``curl`` the Manage API directly while ``--deny- + writes`` etc. silently let it through. + + Requires interactive confirmation (random code) to prevent AI agents + from re-enabling env-var reads programmatically. + """ + formatter = get_formatter(ctx) + + if not _require_interactive_confirmation("deny env-var manage tokens"): + formatter.error( + message="Confirmation failed. Manage-env policy not changed.", + error_code=ErrorCode.PERMISSION_DENIED, + ) + raise typer.Exit(code=EXIT_PERMISSION_DENIED) from None + + config_store: ConfigStore = get_service(ctx, "config_store") + config = config_store.load() + config.allow_env_manage_token = False + config_store.save(config) + + if formatter.json_mode: + formatter.output( + { + "status": "ok", + "allow_env_manage_token": False, + "message": "Env-var manage tokens are now denied. TTY prompt only.", + } + ) + else: + formatter.console.print( + "[bold red]Env-var manage tokens DENIED.[/bold red] " + "Future kbagent invocations refuse KBC_MANAGE_API_TOKEN and " + "KBC_MANAGE_TOKEN_; TTY prompt only." + ) + + +@permissions_app.command("allow-manage-env") +def permissions_allow_manage_env( + ctx: typer.Context, +) -> None: + """Re-allow reading manage tokens from environment variables (default). + + Reverts ``allow_env_manage_token`` to True. Use to undo + ``kbagent permissions deny-manage-env`` when leaving an AI-sandbox + workspace or moving the install to a CI runner. + + Requires interactive confirmation (random code). + """ + formatter = get_formatter(ctx) + + if not _require_interactive_confirmation("allow env-var manage tokens"): + formatter.error( + message="Confirmation failed. Manage-env policy not changed.", + error_code=ErrorCode.PERMISSION_DENIED, + ) + raise typer.Exit(code=EXIT_PERMISSION_DENIED) from None + + config_store: ConfigStore = get_service(ctx, "config_store") + config = config_store.load() + config.allow_env_manage_token = True + config_store.save(config) + + if formatter.json_mode: + formatter.output( + { + "status": "ok", + "allow_env_manage_token": True, + "message": "Env-var manage tokens are now allowed.", + } + ) + else: + formatter.console.print( + "[green]Env-var manage tokens ALLOWED.[/green] " + "KBC_MANAGE_API_TOKEN and KBC_MANAGE_TOKEN_ may be used " + "(unless overridden by the session flag --no-env-manage-token)." + ) + + @permissions_app.command("check") def permissions_check( ctx: typer.Context, diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 409e94ea..ac4e7456 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -394,7 +394,11 @@ 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 (via per-stack + `KBC_MANAGE_TOKEN_` env var since v0.27.1, with + `KBC_MANAGE_API_TOKEN` as the legacy single-stack fallback, or + interactive prompt). With `--all` across multiple stacks, the + resolver fires once per distinct stack with caching. \b Examples: @@ -420,19 +424,47 @@ def project_refresh( ) raise typer.Exit(code=2) - manage_token = resolve_manage_token() - aliases = [project] if project else None - # Build kwargs shared by preview and real call + # Build kwargs shared by preview and real call. Manage tokens are + # stack-scoped: in single-project mode we resolve once for the alias's + # stack; in --all mode we pass a per-stack resolver so refresh_tokens + # can lazily fetch a distinct token for each stack present in the + # refresh set. + config_store = ctx.obj["config_store"] + allow_manage_env = ctx.obj["allow_manage_env"] + refresh_kwargs: dict = { - "manage_token": manage_token, "aliases": aliases, "token_description": token_description, "token_expires_in": token_expires_in, "force": force, } + if project: + # Single project: resolve eagerly so any TTY prompt happens before + # the preview rather than mid-render. + cfg = config_store.load() + target = cfg.projects.get(project) + if target is None: + formatter.error( + message=f"Project '{project}' not found in config", + error_code=ErrorCode.CONFIG_ERROR, + ) + raise typer.Exit(code=5) + refresh_kwargs["manage_token"] = resolve_manage_token( + stack_url=target.stack_url, allow_env=allow_manage_env + ) + else: + # --all: resolve lazily per distinct stack inside the service. The + # resolver caches its results in resolve_manage_token's caller + # context (the service holds the cache; this lambda is just the + # bridge). Multi-stack configs prompt at most once per stack. + def _resolve_for_stack(stack_url: str) -> str: + return resolve_manage_token(stack_url=stack_url, allow_env=allow_manage_env) + + refresh_kwargs["manage_token_resolver"] = _resolve_for_stack + # Interactive safety: show preview first, then confirm interactive = not formatter.json_mode and not yes and not dry_run if interactive: diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index d2a170d8..d8698f2e 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -130,6 +130,10 @@ ENV_KBC_TOKEN: str = "KBC_TOKEN" ENV_KBC_STORAGE_API_URL: str = "KBC_STORAGE_API_URL" ENV_KBC_MANAGE_API_TOKEN: str = "KBC_MANAGE_API_TOKEN" +# Per-stack form: KBC_MANAGE_TOKEN_, e.g. KBC_MANAGE_TOKEN_EU_CENTRAL_1 +# for connection.eu-central-1.keboola.com. Suffix derived from the stack hostname +# at call time; see commands._helpers._stack_suffix_for_env_var(). +ENV_KBC_MANAGE_TOKEN_PREFIX: str = "KBC_MANAGE_TOKEN_" ENV_KBC_MASTER_TOKEN: str = "KBC_MASTER_TOKEN" ENV_MCP_TOOL_TIMEOUT: str = "KBAGENT_MCP_TOOL_TIMEOUT" ENV_MCP_INIT_TIMEOUT: str = "KBAGENT_MCP_INIT_TIMEOUT" diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 84c746cd..fa0540b9 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -82,6 +82,18 @@ class AppConfig(BaseModel): default=None, description="Firewall-style permission policy (None = no restrictions)", ) + allow_env_manage_token: bool = Field( + default=True, + description=( + "Whether resolve_manage_token() may read the token from environment " + "variables (KBC_MANAGE_TOKEN_ or KBC_MANAGE_API_TOKEN). " + "Set to False via `kbagent permissions deny-manage-env` (or " + "automatically by `kbagent init --read-only`) to refuse env-var " + "manage tokens — useful inside AI-agent sandboxes where env is " + "outside the kbagent permission firewall and any subprocess can " + "exfiltrate the token via raw HTTP." + ), + ) projects: dict[str, ProjectConfig] = Field( default_factory=dict, description="Map of alias -> ProjectConfig", diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index e2887204..ce850c5c 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -166,6 +166,8 @@ "permissions.show": "read", "permissions.set": "admin", "permissions.reset": "admin", + "permissions.deny-manage-env": "admin", + "permissions.allow-manage-env": "admin", "permissions.check": "read", } diff --git a/src/keboola_agent_cli/services/org_service.py b/src/keboola_agent_cli/services/org_service.py index 33c3e686..f940ec92 100644 --- a/src/keboola_agent_cli/services/org_service.py +++ b/src/keboola_agent_cli/services/org_service.py @@ -9,6 +9,9 @@ from collections.abc import Callable from typing import Any +import click +import typer + from ..client import KeboolaClient from ..config_store import ConfigStore from ..constants import DEFAULT_TOKEN_DESCRIPTION @@ -18,6 +21,18 @@ logger = logging.getLogger(__name__) +# typer.Exit/Abort (and the underlying click.exceptions.Exit/Abort) are +# control-flow signals for CLI early termination, NOT errors. They inherit +# Exception, so any naive `except Exception` swallows them and would +# convert a clean exit-2 from resolve_manage_token into a confusing +# per-project failure. Re-raise these explicitly before the catch-all. +_CONTROL_FLOW_EXCEPTIONS: tuple[type[BaseException], ...] = ( + typer.Exit, + typer.Abort, + click.exceptions.Exit, + click.exceptions.Abort, +) + ManageClientFactory = Callable[[str, str], ManageClient] StorageClientFactory = Callable[[str, str], KeboolaClient] @@ -227,12 +242,14 @@ def setup_organization( def refresh_tokens( self, - manage_token: str, + manage_token: str | None = None, aliases: list[str] | None = None, token_description: str = DEFAULT_TOKEN_DESCRIPTION, dry_run: bool = False, token_expires_in: int | None = None, force: bool = False, + *, + manage_token_resolver: Callable[[str], str] | None = None, ) -> dict[str, Any]: """Refresh storage API tokens for registered projects. @@ -240,19 +257,67 @@ def refresh_tokens( projects with expired or invalid tokens. Already-valid tokens are skipped unless ``force=True``. + Multi-stack callers should pass ``manage_token_resolver`` so a + distinct manage token is used per stack — Manage tokens are + stack-scoped, and reusing a single token across stacks results in + 401s on every project that doesn't match the issuing stack. The + resolver is invoked **lazily, at most once per distinct + ``stack_url``** in the refresh set, with the result cached for the + duration of the call. + Args: - manage_token: Manage API token (for creating new storage tokens). + manage_token: Single manage token used for every project (legacy + single-stack convenience). Mutually exclusive with + ``manage_token_resolver``. aliases: Optional list of project aliases to refresh. If None, all projects are checked. token_description: Description prefix for newly created tokens. dry_run: If True, only preview what would happen without making changes. token_expires_in: Token lifetime in seconds. None means no expiration. force: If True, refresh all tokens even if they are still valid. + manage_token_resolver: Callable invoked with each distinct + ``stack_url`` in the refresh set, returning the manage token + to use for projects on that stack. Use this for any flow + that may span multiple stacks (the typical + ``project refresh --all`` case). Mutually exclusive with + ``manage_token``. Returns: Dict with refresh results including refreshed, valid, skipped, and failed projects. + + Raises: + ValueError: If neither or both of ``manage_token`` and + ``manage_token_resolver`` are provided. """ + if manage_token is None and manage_token_resolver is None: + raise ValueError("refresh_tokens requires either manage_token or manage_token_resolver") + if manage_token is not None and manage_token_resolver is not None: + raise ValueError( + "refresh_tokens accepts manage_token OR manage_token_resolver, not both" + ) + + # Per-stack token cache. The resolver is invoked at most once per + # distinct stack_url across the entire refresh; failures inside the + # resolver propagate to the caller (typer.Exit from + # resolve_manage_token, etc.) rather than being swallowed here. + _token_by_stack: dict[str, str] = {} + + def _token_for(stack_url: str) -> str: + cached = _token_by_stack.get(stack_url) + if cached is not None: + return cached + if manage_token is not None: + # Legacy single-token path: same token for every stack. Only + # correct when projects share one stack — the multi-stack + # path requires manage_token_resolver. + resolved = manage_token + else: + assert manage_token_resolver is not None + resolved = manage_token_resolver(stack_url) + _token_by_stack[stack_url] = resolved + return resolved + config = self._config_store.load() # Determine which projects to check @@ -285,20 +350,39 @@ def refresh_tokens( "token_expires_in": token_expires_in, } - # Resolve manage token owner identity for unique token naming - owner_name = "" - manage_client = self._manage_client_factory( - projects_to_check[0][1].stack_url, - manage_token, - ) - try: - token_info = manage_client.verify_token() - user_info = token_info.get("user", {}) - owner_name = user_info.get("email") or user_info.get("name", "") - except Exception: - logger.debug("Could not resolve manage token owner identity") - finally: - manage_client.close() + # Resolve manage-token owner identity (for unique token naming) per + # stack, since Manage tokens are stack-scoped: the owner email under + # one stack is meaningless under another. Owner name per stack is + # cached alongside the token. + _owner_by_stack: dict[str, str] = {} + + def _owner_for(stack_url: str) -> str: + cached = _owner_by_stack.get(stack_url) + if cached is not None: + return cached + owner = "" + try: + manage_client = self._manage_client_factory(stack_url, _token_for(stack_url)) + try: + token_info = manage_client.verify_token() + user_info = token_info.get("user", {}) + owner = user_info.get("email") or user_info.get("name", "") + finally: + manage_client.close() + except _CONTROL_FLOW_EXCEPTIONS: + # Resolver raised typer.Exit (no env + no TTY) or typer.Abort + # (Ctrl-C during TTY prompt). These are CLI control flow, + # not "owner-name lookup failed" -- bubble up so the caller + # exits cleanly instead of treating every project on this + # stack as a per-project failure. + raise + except Exception: + logger.debug( + "Could not resolve manage token owner identity for %s", + stack_url, + ) + _owner_by_stack[stack_url] = owner + return owner projects_refreshed: list[dict[str, Any]] = [] projects_valid: list[dict[str, Any]] = [] @@ -368,11 +452,11 @@ def refresh_tokens( try: new_token = self._refresh_single_project( - manage_token=manage_token, + manage_token=_token_for(project.stack_url), alias=alias, project=project, token_description=token_description, - owner_name=owner_name, + owner_name=_owner_for(project.stack_url), token_expires_in=token_expires_in, ) projects_refreshed.append( @@ -384,6 +468,12 @@ def refresh_tokens( "action": "refreshed", } ) + except _CONTROL_FLOW_EXCEPTIONS: + # The lazy resolver raised typer.Exit (no env + no TTY for + # this stack) or the user pressed Ctrl-C during the TTY + # prompt. Don't bury the signal as a per-project failure -- + # bubble up so the CLI exits cleanly with code 2. + raise except Exception as exc: failed.append( { diff --git a/tests/test_cli.py b/tests/test_cli.py index 8ea5c3d0..8002266a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7829,6 +7829,199 @@ def test_init_creates_local_config( config_path = tmp_path / ".kbagent" / "config.json" assert config_path.is_file() + def test_init_read_only_denies_env_manage_token_by_default( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """init --read-only sets allow_env_manage_token=False so a sandboxed + agent cannot exfiltrate KBC_MANAGE_API_TOKEN via raw HTTP.""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("KBAGENT_CONFIG_DIR", raising=False) + + # --read-only requires existing projects, so seed via --from-global. + global_dir = tmp_path / "global-config" + global_dir.mkdir() + + from keboola_agent_cli.config_store import ConfigStore + from keboola_agent_cli.models import ProjectConfig + + with patch("keboola_agent_cli.cli.resolve_config_dir") as mock_resolve: + mock_resolve.return_value = (global_dir, "global") + global_store = ConfigStore(config_dir=global_dir, source="global") + global_store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-xxx-testtoken1234", + project_name="Production", + project_id=1234, + ), + ) + result = runner.invoke(app, ["--json", "init", "--read-only", "--from-global"]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + + # Read directly to bypass any local-store source caching. + config_path = tmp_path / ".kbagent" / "config.json" + parsed = json.loads(config_path.read_text(encoding="utf-8")) + assert parsed["allow_env_manage_token"] is False + # The existing read-only firewall is still applied. + assert parsed["permissions"]["mode"] == "allow" + assert "cli:write" in parsed["permissions"]["deny"] + + def test_init_without_read_only_keeps_env_manage_token_default_true( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Plain `kbagent init` (no --read-only) leaves allow_env_manage_token=True.""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("KBAGENT_CONFIG_DIR", raising=False) + + result = runner.invoke(app, ["--json", "init"]) + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + + config_path = tmp_path / ".kbagent" / "config.json" + parsed = json.loads(config_path.read_text(encoding="utf-8")) + # Either the field is True (preferred) OR omitted (legacy serializer + # might drop the default). Both load identically via AppConfig. + assert parsed.get("allow_env_manage_token", True) is True + + def test_hint_mode_with_no_env_manage_token_documented_limitation( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """`kbagent --hint client --no-env-manage-token data-app password ...` + should emit hint code WITHOUT executing the command. + + Important documented limitation: the hint renderer emits a template + Python snippet that reads `os.environ["KBC_MANAGE_API_TOKEN"]` -- + because hint mode is for users who need a starting code template, + and the env-var read is the most common pattern in CI scripts. + The `--no-env-manage-token` flag affects ONLY the kbagent process's + own resolver path; it does NOT rewrite the rendered template. + + Users running with `--no-env-manage-token` who also use hint mode + are expected to adapt the rendered snippet to their preferred + token-source pattern (TTY prompt, secrets manager, etc.). This + test pins that contract. + """ + from keboola_agent_cli.config_store import ConfigStore + from keboola_agent_cli.models import AppConfig, ProjectConfig + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("KBAGENT_CONFIG_DIR", raising=False) + + local_dir = tmp_path / ".kbagent" + local_dir.mkdir() + store = ConfigStore(config_dir=local_dir, source="local") + store.save( + AppConfig( + projects={ + "prod": ProjectConfig( + stack_url="https://connection.us-east4.gcp.keboola.com", + token="901-prod-storageTokenValue1234567", + project_name="P", + project_id=5726, + ), + }, + ) + ) + + result = runner.invoke( + app, + [ + "--hint", + "client", + "--no-env-manage-token", + "data-app", + "password", + "--project", + "prod", + "--app-id", + "999", + ], + ) + + # Hint mode exits 0 even with --no-env-manage-token because it + # never invokes the runtime resolver -- it emits a template. + assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output!r}" + # The rendered template DOES contain the env-var read pattern -- + # documenting the current behavior. + assert "KBC_MANAGE_API_TOKEN" in result.output + # And the resolver was NOT invoked (no exit-2 message; no token + # exfiltration attempt either). + assert "Env-var manage tokens are disabled" not in result.output + + def test_persisted_deny_manage_env_blocks_env_resolver_end_to_end( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """End-to-end: when AppConfig.allow_env_manage_token=False is + persisted in config.json, a fresh kbagent invocation refuses + KBC_MANAGE_API_TOKEN -- proving the persisted policy threads + through cli.py -> ctx.obj['allow_manage_env'] -> resolve_manage_token + -> exit 2 in non-interactive mode. This closes the AI-exfil + window the session-flag test cannot fully exercise (the session + flag is by definition for one invocation only).""" + from keboola_agent_cli.config_store import ConfigStore + from keboola_agent_cli.models import AppConfig, ProjectConfig + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("KBAGENT_CONFIG_DIR", raising=False) + monkeypatch.delenv("KBC_MANAGE_TOKEN_US_EAST4_GCP", raising=False) + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "would-have-been-exfiltrated") + + # Seed a sandboxed-style local workspace: a project alias so the + # resolver has a stack_url to derive a per-stack name from, and + # the persisted denial bit set. + local_dir = tmp_path / ".kbagent" + local_dir.mkdir() + store = ConfigStore(config_dir=local_dir, source="local") + cfg = AppConfig( + allow_env_manage_token=False, + projects={ + "prod": ProjectConfig( + stack_url="https://connection.us-east4.gcp.keboola.com", + token="901-prod-storageTokenValue1234567", + project_name="Production", + project_id=5726, + ), + }, + ) + store.save(cfg) + + # Patch DataScienceClient at the module path the data-app command + # imports — if the resolver wrongly let the env token through, the + # service would instantiate this client and the patch would record + # the call. Asserting it was NEVER called is the empirical proof + # that the token did not reach the wire. + with patch( + "keboola_agent_cli.services.data_app_service.DataScienceClient" + ) as mock_ds_client: + result = runner.invoke( + app, + [ + "--json", + "data-app", + "password", + "--project", + "prod", + "--app-id", + "999", + ], + input="", # no TTY -> resolver should exit 2 + ) + + # Resolver MUST exit with code 2 (the documented "no manage token + # available" code), not just any non-zero. A different code would + # mean the resolver let the env token through and the failure + # came from a downstream layer (which is the bug we're guarding + # against). + assert result.exit_code == 2, ( + f"Expected exit 2 from resolver; got {result.exit_code}. Output: {result.output!r}" + ) + # Token must NEVER appear in any output channel. + assert "would-have-been-exfiltrated" not in result.output + # DataScienceClient must NEVER have been instantiated -- the + # resolver exited before any HTTP-client construction. + mock_ds_client.assert_not_called() + def test_init_idempotent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """init does not overwrite existing .kbagent/config.json.""" monkeypatch.chdir(tmp_path) @@ -8283,6 +8476,18 @@ def test_project_refresh_single_project(self, tmp_path: Path) -> None: config_dir = tmp_path / "config" config_dir.mkdir() store = ConfigStore(config_dir=config_dir) + # Single-project refresh now resolves the alias's stack_url up + # front so resolve_manage_token can pick the per-stack env var + # (since v0.27.1). The alias must therefore exist in the config. + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-prod-existingTokenValue123456", + project_name="Production", + project_id=258, + ), + ) single_result = { "projects_refreshed": [ @@ -8305,7 +8510,7 @@ def test_project_refresh_single_project(self, tmp_path: Path) -> None: patch( "keboola_agent_cli.commands.project.resolve_manage_token", return_value="manage-token-123456789012345678", - ), + ) as mock_resolver, ): MockStore.return_value = store @@ -8331,10 +8536,19 @@ def test_project_refresh_single_project(self, tmp_path: Path) -> None: assert len(output["data"]["projects_refreshed"]) == 1 assert output["data"]["projects_refreshed"][0]["alias"] == "prod" - # Verify service was called with aliases=["prod"] + # Verify service was called with aliases=["prod"] AND with a single + # manage_token (NOT a resolver — single-project mode uses the + # legacy path). mock_service.refresh_tokens.assert_called_once() call_kwargs = mock_service.refresh_tokens.call_args[1] assert call_kwargs["aliases"] == ["prod"] + assert call_kwargs["manage_token"] == "manage-token-123456789012345678" + assert "manage_token_resolver" not in call_kwargs + # Resolver invoked with the alias's stack_url so the per-stack + # env-var lookup wins for multi-stack users. + mock_resolver.assert_called_once_with( + stack_url="https://connection.keboola.com", allow_env=True + ) def test_project_refresh_api_error(self, tmp_path: Path) -> None: """project refresh with API error returns appropriate exit code.""" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 096b7ed8..f43a80f5 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -45,6 +45,7 @@ from keboola_agent_cli.cli import app from keboola_agent_cli.client import KeboolaClient from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.models import AppConfig # --------------------------------------------------------------------------- # Environment & skip logic @@ -6082,3 +6083,88 @@ def test_data_app_lifecycle_private_and_redeploy(self) -> None: ) )["data"] assert deploy["config_version"], "deploy must pin a configVersion" + + +@pytest.mark.e2e +@skip_without_credentials +class TestE2EPermissionsManageEnv: + """E2E coverage for `kbagent permissions deny-manage-env` / + `allow-manage-env` (added in v0.27.1). + + These do NOT call any external API (the policy lives in config.json + only) but are exercised under the e2e marker per CLAUDE.md + convention #16: "Every new CLI command MUST have a corresponding E2E + test." Validates the full CliRunner path -- argv parsing, ConfigStore + save side-effect, JSON envelope, and the random-code confirmation + gate -- against a real on-disk config dir. + """ + + @pytest.fixture(autouse=True) + def _per_test_workspace(self, tmp_path: Path) -> Any: + """Each test gets its own scratch config dir so tests don't share + the persisted manage-env policy.""" + self.config_dir = tmp_path / ".kbagent" + self.config_dir.mkdir() + store = ConfigStore(config_dir=self.config_dir, source="local") + store.save(AppConfig()) # default: allow_env_manage_token=True + yield + + def test_deny_then_allow_round_trip(self) -> None: + with patch( + "keboola_agent_cli.commands.permissions._require_interactive_confirmation", + return_value=True, + ): + r = _invoke(self.config_dir, ["--json", "permissions", "deny-manage-env"]) + body = _json_ok(r) + assert body["data"]["allow_env_manage_token"] is False + + cfg = ConfigStore(config_dir=self.config_dir).load() + assert cfg.allow_env_manage_token is False + + r = _invoke(self.config_dir, ["--json", "permissions", "allow-manage-env"]) + body = _json_ok(r) + assert body["data"]["allow_env_manage_token"] is True + + cfg = ConfigStore(config_dir=self.config_dir).load() + assert cfg.allow_env_manage_token is True + + def test_show_reports_persisted_denial(self) -> None: + with patch( + "keboola_agent_cli.commands.permissions._require_interactive_confirmation", + return_value=True, + ): + _invoke(self.config_dir, ["--json", "permissions", "deny-manage-env"]) + + r = _invoke(self.config_dir, ["--json", "permissions", "show"]) + body = _json_ok(r) + assert body["data"]["allow_env_manage_token"] is False + assert body["data"]["active"] is True + + def test_persisted_denial_survives_permissions_reset(self) -> None: + """`reset` clears the firewall policy but MUST NOT silently re- + enable env-var manage tokens for sandboxed installs.""" + with patch( + "keboola_agent_cli.commands.permissions._require_interactive_confirmation", + return_value=True, + ): + # Set a firewall policy AND deny manage-env, then reset. + _invoke( + self.config_dir, + [ + "--json", + "permissions", + "set", + "--mode", + "allow", + "--deny", + "cli:write", + ], + ) + _invoke(self.config_dir, ["--json", "permissions", "deny-manage-env"]) + _invoke(self.config_dir, ["--json", "permissions", "reset"]) + + cfg = ConfigStore(config_dir=self.config_dir).load() + assert cfg.permissions is None, "firewall policy was reset" + assert cfg.allow_env_manage_token is False, ( + "manage-env denial MUST survive `permissions reset`" + ) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index ca1dbeab..0a216037 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,8 +1,13 @@ """Tests for commands._helpers shared command-layer utilities.""" import pytest +import typer -from keboola_agent_cli.commands._helpers import map_error_to_exit_code +from keboola_agent_cli.commands._helpers import ( + _stack_suffix_for_env_var, + map_error_to_exit_code, + resolve_manage_token, +) from keboola_agent_cli.errors import KeboolaApiError, map_error_code_to_type @@ -518,3 +523,223 @@ 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 TestStackSuffixForEnvVar: + """Tests for the hostname-derived per-stack env-var suffix.""" + + @pytest.mark.parametrize( + "stack_url, expected", + [ + ("https://connection.keboola.com", None), + ("https://connection.eu-central-1.keboola.com", "EU_CENTRAL_1"), + ("https://connection.us-east4.gcp.keboola.com", "US_EAST4_GCP"), + ("https://connection.eu-west1.gcp.keboola.com", "EU_WEST1_GCP"), + ( + "https://connection.north-europe.azure.keboola.com", + "NORTH_EUROPE_AZURE", + ), + ], + ) + def test_canonical_stacks(self, stack_url: str, expected: str | None) -> None: + assert _stack_suffix_for_env_var(stack_url) == expected + + @pytest.mark.parametrize( + "stack_url", + [ + None, + "", + "not-a-url", + "https://example.com", + "https://connection.keboola.io", + "https://api.keboola.com", + "https://connection..keboola.com", + ], + ) + def test_malformed_or_non_keboola_returns_none(self, stack_url: str | None) -> None: + assert _stack_suffix_for_env_var(stack_url) is None + + def test_uppercases_and_underscores(self) -> None: + # Hypothetical stack with mixed-case and special chars in middle. + assert ( + _stack_suffix_for_env_var("https://connection.us-EAST-2.aws.keboola.com") + == "US_EAST_2_AWS" + ) + + def test_trailing_slash_path_ignored(self) -> None: + # urlparse extracts the hostname regardless of path/query/fragment. + assert ( + _stack_suffix_for_env_var( + "https://connection.eu-central-1.keboola.com/manage/projects?x=1" + ) + == "EU_CENTRAL_1" + ) + + def test_schemeless_url_returns_none(self) -> None: + """A bare hostname without `https://` (operator typo on `--url`) + silently returns None and falls back to the legacy env var. Pin + this contract so a future "be helpful and prepend https://" + change doesn't accidentally start matching schemeless inputs as + valid stack URLs (which would invite confusion about which suffix + is being derived). + """ + assert _stack_suffix_for_env_var("connection.eu-central-1.keboola.com") is None + assert _stack_suffix_for_env_var("connection.us-east4.gcp.keboola.com") is None + + def test_hostname_suffix_collision_documented(self) -> None: + """Two distinct hostnames can derive the same suffix because non- + alphanumerics collapse to underscore. + + Today every Keboola stack hostname uses only `-` (hyphens), not + `_` (underscores) — verified across all production stacks listed + in `gotchas.md` — so the collision is theoretical. This test + DOCUMENTS the behavior so a future reviewer who introduces an + underscore-bearing hostname (or a hostname with other non-alnum + chars) understands the collision can produce ambiguous env-var + names. + + Mitigation if collision becomes real: tighten _NON_ALNUM to + accept hyphens directly (encode `_` as e.g. `__` to disambiguate), + or introduce a curated mapping. Adding either is non-breaking + for users on existing hyphen-only stacks. + """ + # `foo-bar` and `foo_bar` both collapse to `FOO_BAR`. + a = _stack_suffix_for_env_var("https://connection.foo-bar.keboola.com") + b = _stack_suffix_for_env_var("https://connection.foo_bar.keboola.com") + assert a == "FOO_BAR" + assert b == "FOO_BAR" + assert a == b, "documented collision: hyphens and underscores both collapse" + + +class TestResolveManageToken: + """Tests for resolve_manage_token resolution order and AI-exfil opt-out.""" + + def test_per_stack_env_wins_over_legacy(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KBC_MANAGE_TOKEN_EU_CENTRAL_1", "per-stack-secret") + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "legacy-secret") + result = resolve_manage_token(stack_url="https://connection.eu-central-1.keboola.com") + assert result == "per-stack-secret" + + def test_legacy_used_when_no_per_stack(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KBC_MANAGE_TOKEN_EU_CENTRAL_1", raising=False) + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "legacy-secret") + result = resolve_manage_token(stack_url="https://connection.eu-central-1.keboola.com") + assert result == "legacy-secret" + + def test_legacy_only_when_stack_url_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Backwards-compat: callers that don't pass stack_url get legacy behavior. + # Defensive delenv: a developer running the suite with KBC_MANAGE_TOKEN_* + # in their shell would otherwise see false negatives. + monkeypatch.delenv("KBC_MANAGE_TOKEN_EU_CENTRAL_1", raising=False) + monkeypatch.delenv("KBC_MANAGE_TOKEN_US_EAST4_GCP", raising=False) + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "legacy-secret") + result = resolve_manage_token() + assert result == "legacy-secret" + + def test_legacy_url_uses_legacy_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + # connection.keboola.com has no suffix; legacy env var wins. + monkeypatch.delenv("KBC_MANAGE_TOKEN_EU_CENTRAL_1", raising=False) + monkeypatch.delenv("KBC_MANAGE_TOKEN_US_EAST4_GCP", raising=False) + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "legacy-secret") + result = resolve_manage_token(stack_url="https://connection.keboola.com") + assert result == "legacy-secret" + + def test_allow_env_false_skips_per_stack(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KBC_MANAGE_TOKEN_EU_CENTRAL_1", "per-stack-secret") + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "legacy-secret") + # Force non-TTY so the resolver exits 2 instead of prompting. + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + with pytest.raises(typer.Exit) as exc: + resolve_manage_token( + stack_url="https://connection.eu-central-1.keboola.com", + allow_env=False, + ) + assert exc.value.exit_code == 2 + + def test_allow_env_false_skips_legacy(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "legacy-secret") + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + with pytest.raises(typer.Exit) as exc: + resolve_manage_token(allow_env=False) + assert exc.value.exit_code == 2 + + def test_no_env_no_tty_exits_with_actionable_error( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + monkeypatch.delenv("KBC_MANAGE_TOKEN_EU_CENTRAL_1", raising=False) + monkeypatch.delenv("KBC_MANAGE_API_TOKEN", raising=False) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + with pytest.raises(typer.Exit) as exc: + resolve_manage_token(stack_url="https://connection.eu-central-1.keboola.com") + assert exc.value.exit_code == 2 + captured = capsys.readouterr() + # Names BOTH the per-stack form and the legacy fallback so the user + # knows which to set. + assert "KBC_MANAGE_TOKEN_EU_CENTRAL_1" in captured.err + assert "KBC_MANAGE_API_TOKEN" in captured.err + + def test_no_env_no_tty_no_stack_falls_back_to_legacy_name( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + monkeypatch.delenv("KBC_MANAGE_API_TOKEN", raising=False) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + with pytest.raises(typer.Exit): + resolve_manage_token() + captured = capsys.readouterr() + assert "KBC_MANAGE_API_TOKEN" in captured.err + + def test_allow_env_false_error_message_explains_disabled_env( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + monkeypatch.setenv("KBC_MANAGE_API_TOKEN", "legacy-secret") + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + with pytest.raises(typer.Exit): + resolve_manage_token(allow_env=False) + captured = capsys.readouterr() + # Message explains *why* the env var was ignored, naming the flag. + assert "--no-env-manage-token" in captured.err + + def test_tty_prompt_names_stack_url( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("KBC_MANAGE_TOKEN_EU_CENTRAL_1", raising=False) + monkeypatch.delenv("KBC_MANAGE_API_TOKEN", raising=False) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + captured: dict[str, object] = {} + + def fake_prompt(label: str, hide_input: bool = False) -> str: + captured["label"] = label + captured["hide_input"] = hide_input + return "typed-secret" + + monkeypatch.setattr("typer.prompt", fake_prompt) + result = resolve_manage_token(stack_url="https://connection.eu-central-1.keboola.com") + assert result == "typed-secret" + assert "https://connection.eu-central-1.keboola.com" in str(captured["label"]) + # hide_input MUST be True so the token is never echoed. + assert captured["hide_input"] is True + + def test_tty_prompt_generic_label_when_no_stack( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("KBC_MANAGE_API_TOKEN", raising=False) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + captured: dict[str, str] = {} + + def fake_prompt(label: str, hide_input: bool = False) -> str: + captured["label"] = label + _ = hide_input # silence pyright unused-arg + return "typed-secret" + + monkeypatch.setattr("typer.prompt", fake_prompt) + result = resolve_manage_token() + assert result == "typed-secret" + assert captured["label"] == "Manage API token" diff --git a/tests/test_models.py b/tests/test_models.py index 745a012f..fc15eb8b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -72,6 +72,22 @@ def test_empty_config(self) -> None: assert config.version == 1 assert config.default_project == "" assert config.projects == {} + # Backwards-compat default: env-var manage tokens are allowed unless + # the user opts out (per `permissions deny-manage-env` or + # `kbagent init --read-only`). + assert config.allow_env_manage_token is True + + def test_allow_env_manage_token_round_trip(self) -> None: + """allow_env_manage_token survives JSON round-trip.""" + original = AppConfig(allow_env_manage_token=False) + restored = AppConfig.model_validate_json(original.model_dump_json()) + assert restored.allow_env_manage_token is False + + def test_legacy_config_without_field_loads(self) -> None: + """Configs persisted before this field landed still load (default applies).""" + legacy = '{"version": 1, "default_project": "", "projects": {}}' + config = AppConfig.model_validate_json(legacy) + assert config.allow_env_manage_token is True def test_config_with_projects(self) -> None: """AppConfig can hold multiple project connections.""" diff --git a/tests/test_org_service.py b/tests/test_org_service.py index caeeaede..a38d5cca 100644 --- a/tests/test_org_service.py +++ b/tests/test_org_service.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import MagicMock +import pytest + from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.errors import KeboolaApiError from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse @@ -1197,3 +1199,289 @@ def storage_factory(url: str, token: str) -> MagicMock: assert result["projects_checked"] == 0 assert len(result["projects_refreshed"]) == 0 assert len(result["projects_valid"]) == 0 + + +class TestRefreshTokensMultiStack: + """Tests for the per-stack token resolver path in refresh_tokens. + + These exercise the multi-stack `--all` flow where each project may + live on a different stack and a single shared manage token is wrong + by construction. The resolver is invoked at most once per distinct + stack_url, with the result cached for the duration of the call. + """ + + @staticmethod + def _setup_store(tmp_path: Path, projects: dict[str, dict]) -> ConfigStore: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + for alias, kwargs in projects.items(): + store.add_project(alias, ProjectConfig(**kwargs)) + return store + + @staticmethod + def _make_manage_mock() -> MagicMock: + mock = MagicMock() + mock.create_project_token.return_value = { + "id": "tok-new", + "token": "901-99999-multiStackTokenValue1234", + "description": "kbagent-cli", + } + mock.verify_token.return_value = { + "user": {"email": "admin@test.com", "name": "Admin"}, + } + return mock + + def test_resolver_called_once_per_distinct_stack(self, tmp_path: Path) -> None: + """Two projects on the same stack, two on a different stack -> + the resolver is called exactly twice (once per distinct stack).""" + store = self._setup_store( + tmp_path, + { + "us-a": { + "stack_url": "https://connection.keboola.com", + "token": "901-old-expiredA1234567890123456", + "project_name": "US-A", + "project_id": 1001, + }, + "us-b": { + "stack_url": "https://connection.keboola.com", + "token": "901-old-expiredB1234567890123456", + "project_name": "US-B", + "project_id": 1002, + }, + "eu-a": { + "stack_url": "https://connection.eu-central-1.keboola.com", + "token": "901-old-expiredEU1234567890123456", + "project_name": "EU-A", + "project_id": 2001, + }, + "eu-b": { + "stack_url": "https://connection.eu-central-1.keboola.com", + "token": "901-old-expiredEU2345678901234567", + "project_name": "EU-B", + "project_id": 2002, + }, + }, + ) + + # Storage factory routes to one of two shared mocks depending on + # the token: the OLD/expired tokens (every existing project_token) + # raise INVALID_TOKEN; the NEWLY minted token (returned by + # create_project_token below) verifies cleanly. + old_token_mock = MagicMock() + old_token_mock.verify_token.side_effect = KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + ) + new_token_mock = MagicMock() + new_token_mock.verify_token.return_value = TokenVerifyResponse( + token_id="new", + token_description="kbagent-cli", + project_id=1, + project_name="P", + owner_name="O", + ) + + new_token_value = "901-99999-multiStackTokenValue1234" + + def storage_factory(url: str, token: str) -> MagicMock: + return new_token_mock if token == new_token_value else old_token_mock + + # Per-stack ManageClient mocks so we can assert that the + # owner-name introspection (verify_token) AND token-creation + # workload landed on the right stack -- and exactly once each + # for verify_token (the per-stack `_owner_for` cache). + manage_mocks: dict[str, MagicMock] = { + "https://connection.keboola.com": self._make_manage_mock(), + "https://connection.eu-central-1.keboola.com": self._make_manage_mock(), + } + + def manage_factory(url: str, token: str) -> MagicMock: + return manage_mocks[url] + + service = OrgService( + config_store=store, + manage_client_factory=manage_factory, + storage_client_factory=storage_factory, + ) + + # Track resolver calls + resolver_calls: list[str] = [] + + def resolver(stack_url: str) -> str: + resolver_calls.append(stack_url) + return f"manage-token-for-{stack_url}-padded-padded-padded" + + result = service.refresh_tokens(manage_token_resolver=resolver) + + # Resolver invoked exactly twice -- once per distinct stack. + assert len(resolver_calls) == 2 + assert set(resolver_calls) == { + "https://connection.keboola.com", + "https://connection.eu-central-1.keboola.com", + } + # `verify_token` (owner-name introspection) MUST be called exactly + # once per distinct stack, NOT once per project. This pins the + # per-stack `_owner_for` cache invariant -- without it, a 4-project + # x 2-stack refresh would issue 4 verify_token calls instead of 2. + for stack_url, mock in manage_mocks.items(): + assert mock.verify_token.call_count == 1, ( + f"verify_token on {stack_url} called " + f"{mock.verify_token.call_count} times; expected exactly 1 " + "(once per distinct stack, not once per project)" + ) + # `create_project_token` IS called once per project (4 total), + # split 2-and-2 across the two stacks. + assert sum(m.create_project_token.call_count for m in manage_mocks.values()) == 4 + # All four projects refreshed. + assert result["projects_checked"] == 4 + assert len(result["projects_refreshed"]) == 4 + + def test_legacy_single_token_path_still_works(self, tmp_path: Path) -> None: + """Backwards compat: passing manage_token (no resolver) keeps the + legacy behavior where the same token is reused for every project.""" + store = self._setup_store( + tmp_path, + { + "prod": { + "stack_url": "https://connection.keboola.com", + "token": "901-old-expiredTokenValue123456789", + "project_name": "Prod", + "project_id": 100, + }, + }, + ) + + mock_storage = MagicMock() + mock_storage.verify_token.side_effect = [ + KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + ), + TokenVerifyResponse( + token_id="new", + token_description="kbagent-cli", + project_id=100, + project_name="Prod", + owner_name="Owner", + ), + ] + + def storage_factory(url: str, token: str) -> MagicMock: + return mock_storage + + manage_mock = MagicMock() + manage_mock.create_project_token.return_value = { + "id": "tok-new", + "token": "901-99999-legacyPathToken1234567890", + "description": "kbagent-cli", + } + manage_mock.verify_token.return_value = { + "user": {"email": "admin@test.com"}, + } + + def manage_factory(url: str, token: str) -> MagicMock: + return manage_mock + + service = OrgService( + config_store=store, + manage_client_factory=manage_factory, + storage_client_factory=storage_factory, + ) + + result = service.refresh_tokens(manage_token="legacy-token-padded-1234567890") + assert len(result["projects_refreshed"]) == 1 + + def test_neither_token_nor_resolver_raises(self, tmp_path: Path) -> None: + store = self._setup_store( + tmp_path, + { + "prod": { + "stack_url": "https://connection.keboola.com", + "token": "901-old-expiredTokenValue123456789", + "project_name": "Prod", + "project_id": 100, + }, + }, + ) + service = OrgService( + config_store=store, + manage_client_factory=lambda u, t: MagicMock(), + storage_client_factory=lambda u, t: MagicMock(), + ) + with pytest.raises(ValueError, match="manage_token or manage_token_resolver"): + service.refresh_tokens() + + def test_both_token_and_resolver_raises(self, tmp_path: Path) -> None: + store = self._setup_store( + tmp_path, + { + "prod": { + "stack_url": "https://connection.keboola.com", + "token": "901-old-expiredTokenValue123456789", + "project_name": "Prod", + "project_id": 100, + }, + }, + ) + service = OrgService( + config_store=store, + manage_client_factory=lambda u, t: MagicMock(), + storage_client_factory=lambda u, t: MagicMock(), + ) + with pytest.raises(ValueError, match="not both"): + service.refresh_tokens( + manage_token="legacy-token-padded-1234567890", + manage_token_resolver=lambda _u: "resolver-token", + ) + + def test_resolver_typer_exit_propagates_not_swallowed(self, tmp_path: Path) -> None: + """If the lazy resolver raises typer.Exit (no env + no TTY), the + for-loop's `except Exception` MUST NOT swallow it as a per-project + failure. Exit-2 is CLI control flow and the user expects a clean + non-zero exit, not a "failed: SystemExit" entry in projects_failed.""" + import typer + + store = self._setup_store( + tmp_path, + { + "prod": { + "stack_url": "https://connection.eu-central-1.keboola.com", + "token": "901-old-expiredEU1234567890123456", + "project_name": "EU", + "project_id": 2001, + }, + }, + ) + + # Storage check returns INVALID_TOKEN so we enter the refresh path + # where _token_for is invoked. + old_token_mock = MagicMock() + old_token_mock.verify_token.side_effect = KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + ) + + def storage_factory(url: str, token: str) -> MagicMock: + return old_token_mock + + # The resolver simulates `resolve_manage_token` raising typer.Exit + # in non-interactive mode with no env var set. + def resolver(stack_url: str) -> str: + raise typer.Exit(code=2) + + service = OrgService( + config_store=store, + manage_client_factory=lambda u, t: MagicMock(), + storage_client_factory=storage_factory, + ) + + # Must propagate the typer.Exit, NOT return a result with the + # exit recorded as a per-project failure. + with pytest.raises(typer.Exit) as exc: + service.refresh_tokens(manage_token_resolver=resolver) + assert exc.value.exit_code == 2 diff --git a/tests/test_permissions_cli.py b/tests/test_permissions_cli.py index 8f422108..c31261ac 100644 --- a/tests/test_permissions_cli.py +++ b/tests/test_permissions_cli.py @@ -322,6 +322,118 @@ def test_reset_rejected_without_confirmation(self, tmp_path: Path) -> None: assert config.permissions is not None +class TestPermissionsManageEnv: + """Tests for `kbagent permissions deny-manage-env` / `allow-manage-env`.""" + + def test_deny_manage_env_persists_false(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch( + "keboola_agent_cli.commands.permissions._require_interactive_confirmation", + return_value=True, + ), + ): + MockStore.return_value = store + result = runner.invoke(app, ["--json", "permissions", "deny-manage-env"]) + assert result.exit_code == 0, result.output + cfg = store.load() + assert cfg.allow_env_manage_token is False + # JSON envelope reports the new state. + body = json.loads(result.output) + assert body["status"] == "ok" + assert body["data"]["allow_env_manage_token"] is False + + def test_deny_manage_env_rejected_without_confirmation(self, tmp_path: Path) -> None: + """Without TTY confirmation, the policy MUST stay True (the fail- + closed protection that prevents an AI agent from flipping the + policy programmatically).""" + store = _make_store(tmp_path) + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch( + "keboola_agent_cli.commands.permissions._require_interactive_confirmation", + return_value=False, + ), + ): + MockStore.return_value = store + result = runner.invoke(app, ["--json", "permissions", "deny-manage-env"]) + assert result.exit_code == EXIT_PERMISSION_DENIED + cfg = store.load() + assert cfg.allow_env_manage_token is True, ( + "policy must NOT have been flipped without confirmation" + ) + + def test_allow_manage_env_reverts_to_true(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + # Pre-set the persisted denial. + cfg = store.load() + cfg.allow_env_manage_token = False + store.save(cfg) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch( + "keboola_agent_cli.commands.permissions._require_interactive_confirmation", + return_value=True, + ), + ): + MockStore.return_value = store + result = runner.invoke(app, ["--json", "permissions", "allow-manage-env"]) + assert result.exit_code == 0, result.output + cfg = store.load() + assert cfg.allow_env_manage_token is True + body = json.loads(result.output) + assert body["data"]["allow_env_manage_token"] is True + + def test_allow_manage_env_rejected_without_confirmation(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + cfg = store.load() + cfg.allow_env_manage_token = False + store.save(cfg) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch( + "keboola_agent_cli.commands.permissions._require_interactive_confirmation", + return_value=False, + ), + ): + MockStore.return_value = store + result = runner.invoke(app, ["--json", "permissions", "allow-manage-env"]) + assert result.exit_code == EXIT_PERMISSION_DENIED + cfg = store.load() + assert cfg.allow_env_manage_token is False, ( + "denial MUST persist when allow-manage-env is rejected by confirmation" + ) + + def test_reset_does_not_touch_manage_env_policy(self, tmp_path: Path) -> None: + """`permissions reset` clears the firewall policy but DELIBERATELY + leaves allow_env_manage_token alone -- the manage-env axis is + separate. Use `permissions allow-manage-env` to revert it.""" + policy = PermissionPolicy(mode="allow", deny=["cli:write"]) + store = _make_store(tmp_path, policy) + cfg = store.load() + cfg.allow_env_manage_token = False + store.save(cfg) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch( + "keboola_agent_cli.commands.permissions._require_interactive_confirmation", + return_value=True, + ), + ): + MockStore.return_value = store + result = runner.invoke(app, ["--json", "permissions", "reset"]) + assert result.exit_code == 0 + cfg = store.load() + assert cfg.permissions is None, "firewall policy should be cleared" + assert cfg.allow_env_manage_token is False, ( + "manage-env policy MUST survive `permissions reset`" + ) + + class TestPermissionsCheck: """Tests for `kbagent permissions check`.""" diff --git a/uv.lock b/uv.lock index 0363ff09..fb53eb0c 100644 --- a/uv.lock +++ b/uv.lock @@ -439,7 +439,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.27.0" +version = "0.27.1" source = { editable = "." } dependencies = [ { name = "httpx" },