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
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,25 @@ kbagent auth register-projects [--stack URL|alias] [--all] [--project-id ID ...]
# applies retroactively to `auth login --register-projects` (now suffixes on an alias collision
# instead of silently skipping the second project). See docs/programmatic-auth-login-plan.md
# section 4.5 for the full design.
# `auth` over `kbagent serve` (since vNEXT): `register-projects` / `status` / the project-candidate
# listing get a `server/routers/auth.py` counterpart -- `POST /auth/register-projects`,
# `GET /auth/status`, `GET /auth/projects` (the interactive picker's data source; no CLI leaf
# command of its own). `login` / `login-password` / `logout` deliberately have NO endpoint -- a
# browser login only completes on the host, a password grant must never sit behind the serve
# bearer token, and revoking the session is a host-operator action, not a remote one. `/auth/*`
# is also the FIRST router to enforce the `permissions` policy: every route declares
# `Depends(require_permission(...))`, so a denial answers HTTP 403 `PERMISSION_DENIED` over REST
# exactly as on the CLI. The other ~30 routers do not check the engine yet.
# The policy comes from the config dir `serve` RESOLVES (its own `--config-dir`, then
# KBAGENT_CONFIG_DIR, then the local/global chain) -- not from the root callback's `--config-dir`
# -- plus the session flags of the invocation. Reachable ways to enforce: a persisted narrow
# policy (`kbagent permissions set --mode allow --deny auth.register-projects`, needs a real
# terminal for the confirmation code), or `--mode deny` with `serve` (and the reads you want) in
# the allow list. `kbagent --deny-writes serve` does NOT work: `serve` is admin-class and
# `--deny-writes` appends `cli:write`, which spans write+destructive+admin, so the CLI callback
# blocks the `serve` command itself (exit 6) and no server ever starts. `--deny-destructive`
# starts the server but no `/auth/*` operation is destructive, so it affects nothing here.
# See docs/web-server.md.

kbagent project add --project NAME --url URL --token TOKEN
kbagent project list
Expand Down
10 changes: 7 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -824,9 +824,13 @@ live Typer command tree as the single source of truth and fails if any command
is missing from `permissions.py` `OPERATION_REGISTRY`, `CLAUDE.md`
`## All CLI Commands`, `commands/context.py` `AGENT_CONTEXT`, or
`commands-reference.md`. It also flags dead `OPERATION_REGISTRY` keys (renamed /
removed commands). This is the deterministic half of the "Plugin synchronization
map" -- the judgement half (is a behaviour change worth a new gotcha? is the
`(since vX.Y.Z)` tag right?) is left to `/kbagent:review`.
removed commands). A registry key that intentionally has no CLI leaf command --
e.g. a `kbagent serve`-only REST operation like `auth.projects` -- must be
added to `SERVE_ONLY_OPERATIONS` in `permissions.py`, which the script
subtracts before that dead-key check; otherwise it fails CI as if the command
had been renamed or removed. This is the deterministic half of the "Plugin
synchronization map" -- the judgement half (is a behaviour change worth a new
gotcha? is the `(since vX.Y.Z)` tag right?) is left to `/kbagent:review`.

### `.github/workflows/e2e.yml` -- nightly + on-demand (NOT per-PR)

Expand Down
12 changes: 11 additions & 1 deletion docs/web-server-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@ auth, and the concepts behind these routes live in
[`web-server.md`](web-server.md); a running server serves the same spec
interactively at `/docs` (Swagger) and `/openapi.json`.

**228 operations** across **199 paths** and **29 routers**.
**231 operations** across **202 paths** and **30 routers**.

Paths are shown as the server registers them. Reaching them through the
Node BFF (or single-process `--ui` mode) prefixes every path with `/api`.

## Project Management

### `auth` (3 operations)

Read/audit the current browser-login session and register its accessible projects as local aliases. `login` / `login-password` / `logout` have no endpoint here -- see `server/routers/auth.py`. Mirrors `kbagent auth status|register-projects` (partially).

| Method | Path | Summary |
|---|---|---|
| `GET` | `/auth/projects` | List the session's registerable project candidates |
| `POST` | `/auth/register-projects` | Register accessible projects as local aliases |
| `GET` | `/auth/status` | Session health for a stack |

### `projects` (11 operations)

Register, list, edit, and remove Keboola project aliases. Mirrors `kbagent project add|list|remove|edit|status|use|current|info`.
Expand Down
132 changes: 114 additions & 18 deletions docs/web-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ The routers group into the categories declared in

| Category | Routers |
|---|---|
| Project Management | `projects` `members` `org` `feature` `billing` `token` |
| Project Management | `auth` `projects` `members` `org` `feature` `billing` `token` |
| Configurations | `configs` `components` `transformations` `encrypt` |
| Data | `storage` `stream` `search` `sharing` |
| Execution | `jobs` `flows` `schedules` `notifications` `data-apps` `workspaces` |
Expand All @@ -98,13 +98,16 @@ The routers group into the categories declared in
`ai-chat` is the one router with no CLI counterpart — it exists to stream
the web UI's chat. `agents` mirrors `kbagent agent *` (both sides read the
same `agents.json`); what is serve-only there is the cron loop, so a
scheduled task fires only while the server runs.
scheduled task fires only while the server runs. `auth` mirrors only the
read/audit half of `kbagent auth` *(since vNEXT)* — `login` /
`login-password` / `logout` deliberately have no endpoint — and it is so far
the only router that enforces the permission policy; see "`/auth/*` — three
read/audit endpoints, three deliberate gaps" below.

Going the other way, several CLI surfaces are deliberately CLI-only:
`auth` (see "The `auth` command group has no REST router" below), `sync`
Going the other way, several CLI surfaces are deliberately CLI-only: `sync`
(filesystem-local by design), `permissions`, and `init`. The mirrors still
considered missing are tracked in #657, and the fact that `permissions`
does not constrain serve at all is #655.
constrains only `/auth/*` and not the other ~30 routers is #655.

Auto-generated OpenAPI spec at `/openapi.json`, Swagger UI at `/docs`.

Expand Down Expand Up @@ -462,19 +465,112 @@ a session-backed project from the web UI at all:
For a project you would rather not expose this way, register it with a static
Storage token (`kbagent project add --token`) — that path has neither property.

### The `auth` command group has no REST router — including `login-password`

`kbagent auth login` / `login-password` / `status` / `logout` /
`register-projects` have no `server/routers/auth.py` counterpart; this is a
whole-group skip (CONTRIBUTING.md's 1:1 CLI/REST convention), not a per-command
gap. It is a deliberate omission for `login-password` specifically: exposing a
password grant over `serve` would let whoever holds `KBAGENT_SERVE_TOKEN`
submit arbitrary account credentials through this process, which is a strictly
worse blast radius than the existing "serve token borrows a session identity"
tradeoff above — that one requires a session to already exist; this one would
let a caller mint one. Sign in via the CLI directly (`kbagent auth
login-password`, or `auth login` for a human), then register the resulting
session's projects for `serve` to use.
### `/auth/*` — three read/audit endpoints, three deliberate gaps *(since vNEXT)*

`kbagent auth` now has a `server/routers/auth.py` counterpart, but it mirrors
only the read/audit half of the CLI group:

| Endpoint | CLI equivalent | Permission op |
|---|---|---|
| `GET /auth/projects?stack=` | the interactive picker inside `auth register-projects` (no CLI leaf command of its own) | `auth.projects` (read) |
| `POST /auth/register-projects` | `auth register-projects --all` / `--project-id ID ...` | `auth.register-projects` (write) |
| `GET /auth/status?stack=` | `auth status` | `auth.status` (read) |

`POST /auth/register-projects` takes a body of `{stack?, all?, project_ids?,
aliases?}` (`all` is the wire alias for the service's `select_all`; `aliases`
maps a numeric project id to an alias override, coerced from the JSON body's
string keys) and returns the same `registered` / `exists` / `skipped`
per-project statuses the CLI prints — an existing alias is never overwritten.
None of the three response shapes (`ProjectCandidatesResult`,
`RegisterProjectsResult`, `AuthStatusResult`) ever carries a token value,
including the `kbc-session://` sentinel.

`/auth/*` is also the **first router to enforce the permission policy**: every
route above declares `Depends(require_permission(...))`, so a denied operation
answers **HTTP 403** with `error_code: PERMISSION_DENIED` — the same code the
CLI exits on. The other ~30 routers do not check the engine yet; see the
gotchas entry on this before assuming a deny policy firewalls the whole REST
surface.

The policy in force is the **persisted `permissions` block of the config dir
`serve` resolves**, plus whichever session flags the `kbagent` invocation
carried. Two consequences worth knowing before you reach for a flag:

- **`kbagent --deny-writes serve` never starts the server.** `serve` is
classified `admin`, and `--deny-writes` appends `cli:write`, which spans
write + destructive + admin — so the CLI callback blocks the `serve` command
itself (exit code 6, `Operation 'serve' is blocked by the active permission
policy`). `--deny-destructive` does start the server, but no `/auth/*`
operation is destructive, so it changes nothing here.
- **Use a persisted policy instead.** Run, on the host, in a real terminal
(`permissions set` requires a typed confirmation code — there is no `--yes`):

```bash
kbagent --config-dir /path/to/cfg permissions set \
--mode allow --deny auth.register-projects
kbagent serve --config-dir /path/to/cfg --port 8001
```

`POST /auth/register-projects` then answers 403 `PERMISSION_DENIED` while
`GET /auth/projects` and `GET /auth/status` stay reachable. A `--mode deny`
policy works too, but its allow list must then include `serve` (and the reads
you want to keep), or the server will not start for the same reason as above.

Pass `--config-dir` **to `serve`**: the server resolves its own config dir
(`--config-dir` on the `serve` command, then `KBAGENT_CONFIG_DIR`, then the
local/global chain), so a root-level `kbagent --config-dir ... serve` sets the
directory for the CLI invocation, not for the served process.

A missing or expired session reaches `GET /auth/projects` and `POST
/auth/register-projects` as a **thrown error**, both funnelled through
`AuthService._introspect_accessible_projects`: no stored session raises
`SESSION_NOT_FOUND`, a stored session whose refresh fails raises
`SESSION_EXPIRED` (via `provider.introspect()`) — both answer **HTTP 401**,
same as every other session-project failure documented above. `GET
/auth/status` is the deliberate exception: it is the probe you call *to find
out* whether a session is dead, so it must not itself fail that way.
`AuthService.status()` catches both cases and always answers **HTTP 200**,
reporting session health in the response body's `status` field instead —
`"missing"` (no stored session), `"expired"` (refresh failed),
`"degraded"` (the auth service was unreachable; locally stored data is shown),
`"refreshed"` (introspection rotated the access token), or `"live"`.

Scope that exactly: **for a missing or expired session `/auth/status` answers
200 and reports health in `status`; an unresolvable stack (4xx) or an
unexpected auth-service failure (502) still surface as errors.** The stack must
resolve before any session is looked at — with no `?stack=` and no default
project to fall back on, `AuthService.status()` raises `ConfigError` and the
route answers 4xx — and any `KeboolaApiError` that is neither
`SESSION_EXPIRED` nor a network code is re-raised rather than swallowed, so it
reaches the central handler (502, or 401 for a session-credential code such as
`SESSION_NOT_FOUND`). So a client detecting a dead session by HTTP status alone
must call `/auth/projects` or `/auth/register-projects`; on `/auth/status` a
200 is the normal answer for a dead session and the caller must read `status`
from the body.

Registering a project through `POST /auth/register-projects` writes the same
`kbc-session://<project_id>` sentinel `auth login --register-projects` would —
so whoever holds `KBAGENT_SERVE_TOKEN` can grow the set of session-backed
projects this server exposes, still acting as the signed-in user for all of
them, per "Session-registered projects" above.

`login` / `login-password` / `logout` deliberately have **no** endpoint:

- `auth login` opens a browser (or prints a device-flow code) on the host and
only completes there — a REST caller has no way to sit in that loop.
- `auth login-password` takes a plaintext password (and, for MFA accounts, a
TOTP seed) meant to flow from a CI secrets store into one `kbagent` CLI
invocation, never as a REST request body sitting behind this server's own
bearer token.
- `auth logout` revokes the live session backing every session-registered
project reachable through this very server. Destroying that session is a
deliberate host-operator action taken at the CLI, not something a REST
client holding the serve bearer token should be able to trigger remotely.

Sign in via the CLI directly (`kbagent auth login-password`, or `auth login`
for a human), then use `POST /auth/register-projects` — or `auth
register-projects` on the CLI — to register the resulting session's projects
for `serve` to use.

### Manage tokens are per-request

Expand Down
49 changes: 49 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -4471,3 +4471,52 @@ though single-project `job list` looked correctly time-ordered.
- Single-project `job list` is unaffected in practice (its one page was
already server-sorted); the fix only changes behavior once 2+ projects are
queried together.

## `kbagent serve` permission enforcement is `/auth/*`-only so far (since vNEXT)

`create_app` builds a `PermissionEngine` from the persisted `permissions`
policy of **the config dir `serve` resolves** (its own `--config-dir`, then
`KBAGENT_CONFIG_DIR`, then the local/global chain), and `kbagent serve`
forwards only the session FLAGS of the invocation on top. But of the ~30
routers, only the three `/auth/*` routes (`server/routers/auth.py`) declare
`Depends(require_permission(...))` -- see `docs/web-server.md` for the endpoint
shapes.

- **`kbagent --deny-writes serve` cannot start the server** -- do not
recommend it. `serve` is classified `admin` in `permissions.py`, and
`--deny-writes` appends `cli:write`, which spans write+destructive+admin, so
the CLI callback blocks the `serve` command itself: `Error: Operation
'serve' is blocked by the active permission policy.`, exit code 6, no
uvicorn. `--deny-destructive` does start the server, but no `/auth/*`
operation is destructive, so it changes nothing on this router.
- **The reachable recipe is a persisted policy in the SERVED config dir.**
Verified live:

```bash
# in a real terminal: `permissions set` demands a typed confirmation code
kbagent --config-dir /path/to/cfg permissions set \
--mode allow --deny auth.register-projects
kbagent serve --config-dir /path/to/cfg --port 8001
# POST /auth/register-projects -> 403 {"error":{"code":"PERMISSION_DENIED"}}
# GET /auth/status -> 200
# GET /auth/projects -> 200 (401 SESSION_NOT_FOUND when no session)
```

A `--mode deny` policy works too, but then `serve` (and the reads you want to
keep) must be in its allow list, or the server will not start either.
Pass `--config-dir` to `serve` itself: the root-level `kbagent --config-dir
... serve` sets the dir for the CLI invocation, not for the server process.
- **A deny policy does NOT firewall the whole REST surface.**
`permissions set --mode deny --deny cli:write` blocks `POST
/auth/register-projects` (HTTP 403, `error_code: PERMISSION_DENIED`) but
does nothing to `POST /storage/tables/{project}` (create a table) or any
other write route on any other router -- those still execute unchecked.
- `GET /auth/projects` backs a registry key (`auth.projects`) with no CLI leaf
command -- `auth register-projects`'s interactive picker is its terminal
equivalent. It is exempted from `scripts/check_command_sync.py`'s dead-key
check via `SERVE_ONLY_OPERATIONS` in `permissions.py`; see CONTRIBUTING.md's
command-sync gate section for the general rule when adding another
serve-only operation.
- Treat this as a gap being closed incrementally, not the design end state:
today a deny policy gates only `/auth/*`, not the ~30 other routers a
session token can otherwise reach.
20 changes: 17 additions & 3 deletions scripts/check_command_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
from keboola_agent_cli.cli import app
from keboola_agent_cli.commands.context import AGENT_CONTEXT
from keboola_agent_cli.commands.repl import _is_group
from keboola_agent_cli.permissions import OPERATION_REGISTRY
from keboola_agent_cli.permissions import OPERATION_REGISTRY, SERVE_ONLY_OPERATIONS

REPO_ROOT = Path(__file__).resolve().parent.parent
CLAUDE_MD = REPO_ROOT / "CLAUDE.md"
Expand Down Expand Up @@ -108,11 +108,20 @@ def find_drift(
groups: list[CommandPath],
*,
registry_keys: set[str],
serve_only_keys: frozenset[str] = frozenset(),
claude_text: str,
context_text: str,
reference_text: str,
) -> list[str]:
"""Return a human-readable block per drifted surface (empty list == clean)."""
"""Return a human-readable block per drifted surface (empty list == clean).

``serve_only_keys`` are registry entries with no CLI leaf command by design
(they guard `kbagent serve` routes). They are exempt from the DEAD-key check
only. Subtracting them from ``registry_keys`` at the call site instead would
also feed the MISSING-key check, so the day a CLI leaf command is added for
one of them (e.g. `auth projects`), the gate would report it as missing from
OPERATION_REGISTRY while the key sat right there.
"""
leaf_keys = {".".join(p) for p in leaves}
all_keys = leaf_keys | {".".join(p) for p in groups}
two_segment = {" ".join(p[:2]) for p in leaves}
Expand All @@ -129,7 +138,7 @@ def find_drift(
"fail-closed default 'write' hides their true risk category):\n" + entries
)

dead_registry = sorted(registry_keys - all_keys)
dead_registry = sorted(registry_keys - all_keys - serve_only_keys)
if dead_registry:
keys = "\n".join(f" {k}" for k in dead_registry)
problems.append(
Expand Down Expand Up @@ -172,6 +181,11 @@ def main() -> int:
leaves,
groups,
registry_keys=set(OPERATION_REGISTRY),
# Serve-only operations have no CLI leaf command by design (they are
# enforced on `kbagent serve` routes), so they are not dead keys. They
# stay in `registry_keys` so a future CLI leaf command with the same
# name still counts as categorised.
serve_only_keys=SERVE_ONLY_OPERATIONS,
claude_text=CLAUDE_MD.read_text(encoding="utf-8"),
context_text=AGENT_CONTEXT,
reference_text=COMMANDS_REFERENCE_MD.read_text(encoding="utf-8"),
Expand Down
Loading