Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ Note: `SKILL.md` instructs Claude to run `kbagent context` as its first step, wh
## All CLI Commands

```
# Global options: --json, --verbose, --no-color, --config-dir, --hint client|service
# Global options: --json, --verbose, --no-color, --config-dir, --hint client|service, --deny-writes, --deny-destructive

kbagent project add --project NAME --url URL --token TOKEN
kbagent project list
Expand All @@ -244,6 +244,8 @@ kbagent project refresh --project ALIAS [--dry-run] [--force] [--yes] [--token-d
kbagent project refresh --all [--dry-run] [--force] [--yes] [--token-description DESC] [--token-expires-in N]
kbagent project description-get --project NAME
kbagent project description-set --project NAME [--text STR | --file PATH | --stdin]
kbagent project use ALIAS
kbagent project current

kbagent config list [--project NAME] [--component-type TYPE] [--component-id ID] [--branch ID]
kbagent config detail --project NAME --component-id ID --config-id ID [--branch ID]
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.21.2",
"version": "0.22.0",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
2 changes: 2 additions & 0 deletions plugins/kbagent/skills/kbagent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ When working inside a git repository or project directory, run `kbagent init` (o
| Edit an existing Keboola project connection | `kbagent project edit --project ALIAS` |
| Test connectivity to connected Keboola projects | `kbagent project status` |
| Refresh expired or invalid Storage API tokens | `kbagent project refresh` |
| Pin <alias> as the default project for subsequent commands | `kbagent project use <ALIAS>` |
| Show the effective default project | `kbagent project current` |
| Get the Keboola dashboard project description | `kbagent project description-get --project PROJECT` |
| Set the Keboola dashboard project description (markdown) | `kbagent project description-set --project PROJECT` |
| Set up projects and register them in the kbagent config | `kbagent org setup --url URL` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ All commands support `--json` for structured output. Multi-project flags (`--pro
- `project status [--project NAME]` -- test connectivity and response time
- `project description-get --project NAME` -- read the dashboard project description (KBC.projectDescription on the default branch). Returns `{"description": ""}` if not set, not an error
- `project description-set --project NAME [--text STR | --file PATH | --stdin]` -- set the dashboard project description (markdown). Pass exactly one of `--text`, `--file`, or `--stdin`. Writes to `KBC.projectDescription` on the default branch -- always the main branch, regardless of any active dev branch
- `project use ALIAS` -- pin `ALIAS` as the persistent default project. Stored as `default_project` in config.json. Overridden at runtime by `KBAGENT_PROJECT=ALIAS` (env, beats pin) and by `--project ALIAS` (CLI flag, beats both)
- `project current` -- print the effective default project and its source (`env` / `pin` / `none`). Reports both the env override AND the persisted pin so misconfigurations are visible. Returns `{"alias": null, "source": "none"}` when neither is set

## 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

## 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`)
Expand Down
26 changes: 26 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,32 @@ kbagent looks for configuration in this order:

Use `kbagent init` to create a local `.kbagent/` workspace for per-directory isolation.

## `KBAGENT_PROJECT` environment variable

Lets callers override the default project for one shell/session without editing
`config.json`. A few non-obvious rules:

- **Empty string counts as unset.** `KBAGENT_PROJECT=""` (or a value consisting
only of whitespace) is treated exactly like the variable not being set at
all. This follows the standard Unix shell convention and prevents a stray
`export KBAGENT_PROJECT=` from silently breaking every subsequent command.
- **Points to an unregistered alias -> hard fail.** If the env var names an
alias that is NOT in your configured projects, write-ops (the ones that
consult the pin) fail with `CONFIG_ERROR` and exit code 5. Repair either by
running `kbagent project use <valid-alias>` and unsetting the env var, or by
`unset KBAGENT_PROJECT`. The CLI will not fall back silently to the persisted
pin -- that would mask a misconfiguration.
- **Precedence for resolving the target project** (highest wins):
1. `--project <alias>` CLI flag (explicit per-command)
2. `KBAGENT_PROJECT` env var
3. Persisted pin (`default_project` in `config.json`, set via
`kbagent project use <alias>`)
4. Sole-project fallback (if exactly one project is configured)
5. Hard fail with `CONFIG_ERROR` (no ambiguous defaulting)
- `kbagent project current` reports which of (2) or (3) is active and flags
when the env var points to an unregistered alias, so you can diagnose
precedence issues without reading the source.

## config update vs MCP update_config

For updating configuration content, prefer `kbagent config update` over MCP's `update_config` tool:
Expand Down
71 changes: 71 additions & 0 deletions plugins/kbagent/skills/kbagent/references/permissions-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,77 @@ kbagent --json permissions list
| `sync.*` | All sync subcommands (glob) |
| `tool:create_*` | MCP tools matching glob pattern |

## Session firewall flags

Two top-level flags let an operator harden a single invocation WITHOUT editing
the persisted policy in `config.json`. They are session-only, additive, and
evaluated alongside any persisted policy.

```bash
# Wide net: blocks writes + destructive + admin
kbagent --deny-writes <command>

# Narrow net: blocks only data-destructive ops (pure writes still allowed)
kbagent --deny-destructive <command>

# Both (equivalent to --deny-writes here, since wide subsumes narrow)
kbagent --deny-writes --deny-destructive <command>
```

### `--deny-writes` (WIDE)

Appends `cli:write` and `tool:write` to the deny list. The `cli:write` pattern
intentionally spans the **write + destructive + admin** categories, so this one
flag blocks everything that mutates state -- config create/update/delete, branch
delete, project add/remove/edit, org setup, storage writes, sync push, and every
MCP write tool (`create_*`, `update_*`, `delete_*`, `add_*`, `set_*`, `remove_*`).

Use this when you want a strict read-only session without touching the persisted
policy. Blocked operations exit with code 6 (`PERMISSION_DENIED`).

### `--deny-destructive` (NARROW)

Appends `cli:destructive` and `tool:destructive` to the deny list. This pattern
matches **only** operations categorized as destructive (data destruction) --
`branch.delete`, `workspace.delete`, `config.delete`, `storage.delete-table`,
`storage.delete-bucket`, `storage.delete-column`, `job.terminate`, and MCP
`delete_*` / `remove_*` tools.

Pure-write operations (e.g. `storage create-bucket`, `config update`) and admin
operations (e.g. `project remove`, `org setup`) are **still allowed**. Use this
when an agent needs to create/modify resources but must not be able to destroy
existing data.

### REPL forwarding

When invoked as `kbagent --deny-writes repl` (or `--deny-destructive`), the
flags propagate into every subcommand run inside the REPL session, so each
inner invocation picks them up automatically. A duplicate-append guard prevents
the flag from being injected twice if a user also types it explicitly on a REPL
line.

### Relationship to persisted policy

The session flags merge **additively** with the persisted policy for the
duration of the invocation:

- The persisted `mode`, `allow` list, and existing `deny` entries are preserved
unchanged. Only the flag-implied deny patterns are appended (deduped).
- Session flags **can only add more deny entries** -- they NEVER relax the
persisted policy. Running `kbagent --deny-writes` against a policy that
already denies everything does not re-open anything.
- The merged policy lives in memory for this process only. It is never written
to `config.json`, so subsequent invocations without the flag revert to the
persisted policy alone.
- `kbagent permissions list` and `kbagent permissions show` render the
**effective** policy (persisted merged with session flags) so you can verify
what is actually active right now. The `session_flags` field in the JSON
output of `permissions show` surfaces which flags are in play.

For the complementary project-pin UX (`kbagent project use <alias>`, which
persists a default project so you can drop `--project` from subsequent
commands), see the project management section of the skill.

## Defense in depth (`--read-only`)

`kbagent init --read-only` applies three layers of protection:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-agent-cli"
version = "0.21.2"
version = "0.22.0"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
8 changes: 8 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.22.0": [
"New: `kbagent project use <alias>` -- pin a project as the default for subsequent commands. Persists `default_project` in config.json (the field already existed; now there is an explicit CLI verb to set it).",
"New: `kbagent project current` -- print the effective default project and its source (env / pin / none). Reports both the env override and the persisted pin so misconfigurations are visible, not silent.",
"New: `KBAGENT_PROJECT` env var overrides the persisted pin for a single shell/session. Resolution precedence for single-project ops: explicit `--project` > `KBAGENT_PROJECT` > pin > sole-project fallback > fail-hard with CONFIG_ERROR.",
"New: top-level `--deny-writes` / `--deny-destructive` flags synthesize a session-only firewall that merges with any persisted permission policy. Never written to config.json. `--deny-writes` blocks the wide net (write+destructive+admin); `--deny-destructive` is narrower and blocks only data destruction.",
"New: `ProjectService.resolve_pinned_alias()` plus `commands._helpers.resolve_project_alias()` -- single-project alias resolution contract for write/destructive commands. Public API for future PRs to adopt; FIIA P0-4 acceptance criterion.",
"Fix: stale pin (default_project pointing at a deleted alias) now raises a repair-friendly CONFIG_ERROR with `kbagent project use <alias>` guidance instead of silently fanning out.",
],
"0.21.2": [
"Fix: `kbagent config search` now scans `rows[].configuration` in addition to the top-level configuration body (#196) -- queries like `--query '\"incremental\": false'` previously returned zero matches for row-based components (Snowflake/MySQL/BigQuery writers, DB extractors, Google Sheets) because the service only fetched `include=configuration`; match paths are now reported as `rows[N].configuration.parameters.<key>`",
"Fix: `kbagent storage tables` now accepts zero-or-more `--project` flags and queries all connected projects in parallel (#198) -- matches the multi-project behaviour of `storage buckets`, `config list`, `job list`; JSON envelope now returns `{tables: [...], errors: [...]}` with per-row `project_alias`; `--branch` still requires exactly one `--project`",
Expand Down
85 changes: 82 additions & 3 deletions src/keboola_agent_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from .config_store import ConfigStore, resolve_config_dir
from .constants import EXIT_PERMISSION_DENIED
from .errors import PermissionDeniedError
from .models import PermissionPolicy
from .output import OutputFormatter
from .permissions import PermissionEngine
from .services.branch_service import BranchService
Expand Down Expand Up @@ -92,6 +93,58 @@
app.add_typer(encrypt_app, name="encrypt", rich_help_panel=_DEV)


def apply_firewall_flags(
persisted: PermissionPolicy | None,
*,
deny_writes: bool,
deny_destructive: bool,
) -> PermissionPolicy | None:
"""Merge --deny-writes / --deny-destructive into the active policy for this invocation.

Session-only: does NOT touch config.json. If neither flag is set, the
persisted policy is returned unchanged (possibly None).

Merge semantics:
- A fresh session policy synthesized from the flags uses mode='allow'
so everything is allowed unless matched by the deny list.
- When a persisted policy already exists, the flag-implied deny patterns
are appended to its deny list (dedup); the mode is preserved. This is
strictly additive -- adding a flag never relaxes the persisted policy.
"""
if not deny_writes and not deny_destructive:
return persisted

extra_deny: list[str] = []
if deny_writes:
# cli:write pattern intentionally spans write+destructive+admin
# (permissions._matches_pattern lines 175-178). tool:write spans
# tool write+destructive. Wide net: --deny-writes blocks anything
# that mutates state.
extra_deny.extend(["cli:write", "tool:write"])
if deny_destructive:
# cli:destructive narrowly matches only ops categorized 'destructive'
# (data destruction). Admin and pure-write are left allowed by design:
# the two flags exist precisely so callers can opt into the narrower
# block without forfeiting writes (e.g. allow create-bucket, block
# delete-bucket).
extra_deny.extend(["cli:destructive", "tool:destructive"])

if persisted is None:
return PermissionPolicy(mode="allow", allow=[], deny=extra_deny)

# Preserve persisted mode, allow list; extend deny list without duplicates.
merged_deny = list(persisted.deny)
for pattern in extra_deny:
if pattern not in merged_deny:
merged_deny.append(pattern)

return PermissionPolicy(
mode=persisted.mode,
allow=list(persisted.allow),
deny=merged_deny,
)


@app.callback()
def main(
ctx: typer.Context,
Expand Down Expand Up @@ -123,6 +176,21 @@ def main(
help="Show equivalent Python code instead of executing. "
"Values: 'client' (direct API usage, default) or 'service' (uses CLI config).",
),
deny_writes: bool = typer.Option(
False,
"--deny-writes",
help="Session-only firewall: block write, destructive, AND admin "
"operations (the wide net -- project add/remove/edit, org setup, "
"storage writes and deletes, etc.). Merges with any persisted policy.",
),
deny_destructive: bool = typer.Option(
False,
"--deny-destructive",
help="Session-only firewall: block ONLY data-destructive operations "
"(storage delete-table/delete-bucket/delete-column, job terminate, "
"branch delete, etc.). Admin ops like 'project remove' and 'org setup' "
"are NOT blocked -- use --deny-writes for the wide net.",
),
) -> None:
"""Global options applied to all commands."""
from .auto_update import maybe_auto_update, show_post_update_changelog
Expand Down Expand Up @@ -199,10 +267,17 @@ def main(

try:
config = config_store.load()
permission_engine = PermissionEngine(config.permissions)
persisted_policy = config.permissions
except Exception:
# Config may be invalid (e.g. corrupted JSON) -- skip permission check
permission_engine = PermissionEngine(None)
# Config may be invalid (e.g. corrupted JSON) -- skip persisted policy
persisted_policy = None

session_policy = apply_firewall_flags(
persisted_policy,
deny_writes=deny_writes,
deny_destructive=deny_destructive,
)
permission_engine = PermissionEngine(session_policy)

# Resolve hint mode
hint_mode = None
Expand All @@ -218,6 +293,8 @@ def main(
ctx.obj["permission_engine"] = permission_engine
ctx.obj["verbose"] = verbose
ctx.obj["no_color"] = effective_no_color
ctx.obj["deny_writes"] = deny_writes
ctx.obj["deny_destructive"] = deny_destructive
ctx.obj["config_store"] = config_store
ctx.obj["project_service"] = project_service
ctx.obj["component_service"] = component_service
Expand Down Expand Up @@ -289,5 +366,7 @@ def main(
verbose=verbose,
no_color=effective_no_color,
config_dir=config_dir,
deny_writes=deny_writes,
deny_destructive=deny_destructive,
)
raise typer.Exit()
37 changes: 37 additions & 0 deletions src/keboola_agent_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,43 @@ def check_cli_permission(ctx: typer.Context, group_name: str) -> None:
raise typer.Exit(code=EXIT_PERMISSION_DENIED) from None


def resolve_project_alias(
ctx: typer.Context,
formatter: OutputFormatter,
explicit: str | None,
) -> str:
"""Resolve the effective project alias for a single-project operation.

Precedence (first match wins):
1. ``explicit`` (typically the CLI ``--project`` flag)
2. ``KBAGENT_PROJECT`` env var
3. Persisted pin (``config.default_project`` set by ``kbagent project use``)
4. Sole registered project when exactly one exists (convenience)
5. Exit code 5 with a CONFIG_ERROR if none of the above resolves

Use this from write/destructive command paths where implicit fan-out
(``resolve_projects(None)`` returning every project) would be surprising
or unsafe. Read paths should keep their existing fan-out behavior.

Args:
ctx: Typer context (must contain ``project_service``).
formatter: Output formatter for structured error emission.
explicit: The value of the CLI --project flag, or None.

Returns:
The resolved project alias (guaranteed to be registered).
"""
from ..errors import ConfigError as _ConfigError

service = get_service(ctx, "project_service")
try:
alias, _source = service.resolve_pinned_alias(explicit=explicit)
except _ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5) from None
return alias


def validate_branch_requires_project(
formatter: OutputFormatter,
branch: int | None,
Expand Down
Loading