Skip to content
23 changes: 21 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ kbagent auth register-projects [--stack URL|alias] [--all] [--project-id ID ...]
# 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.
# exactly as on the CLI. Every other route is checked too since #655 (see `permissions` below).
# The policy comes from the config dir `serve` RESOLVES (its own `--config-dir`, then -- since
# 0.91.0, #679 -- an explicit root `kbagent --config-dir`, then KBAGENT_CONFIG_DIR, then the
# local/global chain) plus the session flags of the invocation. On 0.90.1 the root-level
Expand All @@ -479,7 +479,7 @@ kbagent auth register-projects [--stack URL|alias] [--all] [--project-id ID ...]
# 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.
# starts the server and blocks every destructive route (none of them under `/auth/*`).
# See docs/web-server.md.

kbagent project create --url URL [--project ALIAS] [--name NAME] [--backend snowflake|bigquery] [--sync-backend-init]
Expand Down Expand Up @@ -813,6 +813,25 @@ kbagent token refresh --project NAME --token-id ID [--yes]
# is persisted. `permissions show` / `kbagent doctor` also generalized: they now flag ANY
# persisted pattern matching zero known operations, not only the retired tool:* namespace.
# PermissionEngine itself stays lenient at evaluation time -- only `permissions set` is strict.
# (since vNEXT, #655) The policy also firewalls the WHOLE `kbagent serve` REST surface: every
# route is classified in server/route_permissions.py and checked by one app-level dependency
# against the SERVED config dir's policy -> HTTP 403 error_code PERMISSION_DENIED, the same
# code the CLI exits on. On 0.98.0 and older only /auth/* (0.90.1+) and /merge-requests/*
# (0.94.0+) were checked and every other route executed unchecked. Routes that declare their
# own `require_permission(...)` (those two routers) are skipped by the table. An unclassified
# route is REFUSED, not exempted (a test keeps the table
# and the live app in sync both ways); bootstrap paths (/health/ping, /health/auth-info,
# /ui-config, /docs, /redoc, /openapi.json, SPA shell) are never checked. A path param
# that collapses CLI leaves is checked like the CLI: POST/PUT/DELETE
# /semantic-layer/items/{kind}[/{name}] check the parent key (`semantic-layer.add`) AND
# the leaf `semantic-layer.add.<kind>` (route_permissions.LEAF_PATH_PARAMS).
# A `--scope organization` semantic-layer write is destructive-class over REST too:
# the handlers of scope PUT, models, items/{kind} (per-kind leaf key), import, promote
# and build check `<key> --scope organization` (FLAG_ESCALATIONS) for a typed OR an
# inherited organization scope, like the CLI.
# `GET /permissions/show` (serve, since vNEXT) reports the EFFECTIVE policy (persisted block
# merged with the daemon's --deny-* flags); read-only by design -- no REST route can widen
# the policy that constrains it, so `permissions set|reset` stay terminal actions on the host.
kbagent permissions list [--category read|write|destructive|admin]
kbagent permissions show
kbagent permissions set --mode allow|deny [--allow PATTERN ...] [--deny PATTERN ...]
Expand Down
10 changes: 9 additions & 1 deletion docs/web-server-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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`.

**256 operations** across **221 paths** and **31 routers**.
**257 operations** across **222 paths** and **32 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`.
Expand Down Expand Up @@ -490,6 +490,14 @@ Developer Portal app discovery -- list a vendor's apps, get one app's full entry

## System

### `permissions` (1 operation)

Read the session firewall policy this server enforces on every route (issue #655). Read-only: `permissions set|reset` stay terminal actions on the host, so a bearer token can never widen the policy that constrains it. Mirrors `kbagent permissions show`.

| Method | Path | Summary |
|---|---|---|
| `GET` | `/permissions/show` | Show the active permission policy |

### `health` (6 operations)

Liveness ping, auth-info bootstrap, version, changelog, and doctor checks. `/health/ping` is the only public endpoint -- everything else requires Bearer auth.
Expand Down
109 changes: 94 additions & 15 deletions docs/web-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,13 @@ 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. `auth` mirrors only the
read/audit half of `kbagent auth` *(since v0.90.1)* — `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.
`login-password` / `logout` deliberately have no endpoint.

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`
constrains only `/auth/*` and not the other ~30 routers is #655.
(filesystem-local by design), `init`, and the *write* half of `permissions`
(`set` / `reset`). The mirrors still considered missing are tracked in #657.
`GET /permissions/show` *(since vNEXT)* is the read half — see "The session
firewall applies to every route" below.

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

Expand Down Expand Up @@ -485,23 +484,25 @@ 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.
`/auth/*` was the **first router to enforce the permission policy** (0.90.1):
every route above declares `Depends(require_permission(...))` at its own call
site. Since vNEXT that is no longer special — *every* route on the server is
enforced (see "The session firewall applies to every route" below) — and the
three inline declarations survive only as the per-route override form.

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:
carried. The server reads that block once, when it starts. After
`permissions set` or `permissions reset`, restart `kbagent serve`: until then
the running server keeps the old policy. 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.
policy`). `--deny-destructive` does start the server, and since vNEXT it
blocks every destructive route (no `/auth/*` operation is destructive).
- **Use a persisted policy instead.** Run, on the host, in a real terminal
(`permissions set` requires a typed confirmation code — there is no `--yes`):

Expand Down Expand Up @@ -573,6 +574,84 @@ 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.

### The session firewall applies to every route

*(since vNEXT — issue #655)*

`PermissionEngine` used to be built only in the Typer callback, so a persisted
`permissions set --mode deny` policy — and both `--deny-writes` and
`--deny-destructive` — protected the CLI process and nothing else. `kbagent
serve` exposed every route, `DELETE /storage/buckets` included, behind a single
all-or-nothing bearer token. 0.90.1 closed that for `/auth/*` and 0.94.0 for
`/merge-requests/*`; vNEXT closes it for the whole surface.

**How it works.** One app-level dependency (`server/route_permissions.py`) runs
on every request, looks the matched route up by `(method, path template)` in
`ROUTE_OPERATIONS`, and calls the same `PermissionEngine.check_or_raise` the CLI
uses. A denial answers **HTTP 403** with `error_code: PERMISSION_DENIED` and the
same message the CLI prints, so a client can branch on one value across both
surfaces. The body is the error envelope every other `serve` error uses
(`{"status": "error", "error": {"code", "message"}}`). A route that declares its
own `Depends(require_permission(...))` (`/auth/*`, `/merge-requests/*`) is
checked by that dependency and skipped by the table.

Three properties worth knowing:

- **A route with no classification is refused, not exempted.** Failing open
would make the newest, least-reviewed part of the surface the one outside the
firewall. A test (`tests/test_server_route_permissions.py`) asserts the table
matches the live app in both directions, so the refusal path should never be
reached in a released build — if you hit it, a route was added without a table
entry.
- **No policy configured changes nothing.** With a clean `config.json` and no
session flags, `is_allowed` returns True for everything and the surface
behaves exactly as it did before.
- **Bootstrap paths are never checked** (`UNGUARDED_PATHS`: `/health/ping`,
`/health/auth-info`, `/ui-config`, `/docs`, `/redoc`, `/openapi.json`, and the
SPA shell). A locked-down server must still be able to say who it is;
otherwise a client cannot tell a policy refusal from a dead process.

**Per-kind leaf keys.** `POST /semantic-layer/items/{kind}`, `PUT
/semantic-layer/items/{kind}/{name}` and `DELETE
/semantic-layer/items/{kind}/{name}` cover all five kinds in one route each. Like
the CLI, the server checks two keys for them: the parent (`semantic-layer.add`)
and the leaf built from the `{kind}` path parameter (`semantic-layer.add.metric`).
A policy that names only a leaf key, or a leaf glob like `semantic-layer.add.*`,
is enforced over REST the same as on the CLI. A `mode: deny` policy must allow
both keys, as on the CLI.

**`--scope organization` writes are destructive-class over REST too.** The table
sees the route, not the body, so it checks only the non-flag keys (`write`). An
organization scope is one-way and makes an item visible to every project in the
organization, so `FLAG_ESCALATIONS` classifies it `destructive`, and the
handlers add that check: `PUT /semantic-layer/scope/{context_id}` with
`"scope": "organization"` checks `semantic-layer.scope.set --scope
organization`; `POST /semantic-layer/models`, `POST /semantic-layer/items/{kind}`
(per kind: `semantic-layer.add.<kind> --scope organization`), `POST
/semantic-layer/import` and `POST /semantic-layer/promote` check their own
escalated key when the body asks for `organization`. As on the CLI, an omitted
`scope` that would INHERIT `organization` from the target model (`items`,
`import`, `promote`, and `build` with a `model`) gets the same check; the model
lookup runs only when a policy is active. Under `--deny-destructive` these calls
answer 403 `PERMISSION_DENIED`; the same calls at `project` or `targeted` scope
are allowed.

**Discovering the policy.** `GET /permissions/show` returns the *effective*
policy the server enforces — the persisted block already merged with the
`--deny-*` flags the daemon was launched with, which a REST caller can neither
see nor change:

```json
{"active": true, "policy": {"mode": "allow", "allow": [], "deny": ["cli:destructive"]}}
```

It stays reachable under any policy (`permissions.*` operations are always
allowed, by the same anti-lockout rule the CLI has) but still requires the
bearer token. There is deliberately **no write counterpart**: letting a bearer
token widen the policy that constrains it would make the firewall
self-defeating, so `permissions set` / `reset` stay terminal actions on the
host.

### Manage tokens are per-request

Operations that need a Keboola Manage API token (`org setup`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the

## Permissions (session firewall commands)
The `permissions` subcommands persist a write/destructive policy to config.json (the `--deny-*` flags above are the one-shot form). The engine guards against agent mistakes; it is not a sandbox.
Since vNEXT (#655) the persisted policy also firewalls the whole `kbagent serve` REST surface -- every route is checked against the SERVED config dir's policy and a denial answers HTTP 403 `PERMISSION_DENIED`. On 0.98.0 and older only `/auth/*` (0.90.1+) and `/merge-requests/*` (0.94.0+) were checked. A `--scope organization` semantic-layer write (typed or inherited from the target model) is destructive-class over REST too, as on the CLI. `POST/PUT/DELETE /semantic-layer/items/{kind}` check the parent key and the per-kind leaf key (`semantic-layer.add.metric`), as on the CLI. `GET /permissions/show` (serve-only, since vNEXT) reports the EFFECTIVE policy; there is deliberately no REST route that CHANGES it.
- `permissions list [--category read|write|destructive|admin]` -- list all operations with their risk category and current allowed/denied status
- `permissions show` -- show the current active permission policy. *(since 0.91.0)* also flags ANY persisted pattern matching zero known operations, not only the retired `tool:*` namespace (same generalization applies to `kbagent doctor`) -- a typo like `cli:reed` or `stroage.upload-table` is surfaced too, with a generic "check for typos" hint instead of the MCP-migration one
- `permissions set --mode allow|deny [--allow PATTERN ...] [--deny PATTERN ...]` -- set the permission policy (firewall rules); patterns like `cli:read`, `cli:write`, `cli:destructive`. `tool:*` patterns are INERT since v0.85.0 (the MCP passthrough is gone) -- they load but match nothing, so a `--mode deny` policy whose only allowance was `tool:read` now denies everything. *(since 0.91.0, issue #688)* Every pattern is validated BEFORE the interactive confirmation: it must be a `cli:*` category, an exact operation name, or a glob matching at least one known operation, or the call fails fast with `VALIDATION_ERROR` (exit 2, `error.details.invalid_patterns` in `--json`) and nothing is written
Expand Down Expand Up @@ -493,7 +494,7 @@ CLI parity for the `/agents` REST surface. Reads/writes `<config_dir>/agents.jso
- `doctor` -- health checks (no `--fix` since v0.85.0 -- it only installed the MCP server). Includes a `sync_secrets` check (since 0.55.0): when run inside a sync working tree (`.keboola/manifest.json`), warns if any in-sync config holds plaintext `#`-secrets (#378); `skip` outside a sync tree. Also an `mcp_tool_tasks` check: FAILs when `agents.json` holds tasks using the removed `mcp_tool` action, listing each task in `details.tasks[]` -- map the tool to its native command via `docs/mcp-migration.md` and recreate the task as `--type cli_command`
- `version` -- show the kbagent version and check for kbagent updates (kbagent only since v0.85.0); on a standalone binary it advertises the native channel's upgrade command instead of `kbagent update` (v0.79.0+)
- `context` -- full usage instructions for AI agents
- `serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] [--no-banner]` -- launch the FastAPI HTTP server backing the web UI. `--ui` mounts the built SPA at `/` and authenticates the browser with an HttpOnly `kbagent_session` cookie (nothing is injected into `index.html`). `--no-banner` *(since 0.90.0)* suppresses the UI's "What's new" popup -- a curated per-version highlights modal shown once per version, dismissal persisted in localStorage `kbagent.whatsnew.seen`. The SPA reads the switch from `GET /ui-config` -> `{"banner": bool}`; it gates only the UNSOLICITED popup, so the command palette's "What's new" action still opens it on request. `--config-dir` picks the directory the server exposes (projects AND the `permissions` policy the `/auth/*` routes enforce); *(since 0.91.0)* an explicit root-level `kbagent --config-dir Y serve` is honored too, with the serve-level flag winning when both are given -- on 0.90.1 and older the root-level one was silently ignored, so pass it to `serve` itself there. Requires the optional `server` extra
- `serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] [--no-banner]` -- launch the FastAPI HTTP server backing the web UI. `--ui` mounts the built SPA at `/` and authenticates the browser with an HttpOnly `kbagent_session` cookie (nothing is injected into `index.html`). `--no-banner` *(since 0.90.0)* suppresses the UI's "What's new" popup -- a curated per-version highlights modal shown once per version, dismissal persisted in localStorage `kbagent.whatsnew.seen`. The SPA reads the switch from `GET /ui-config` -> `{"banner": bool}`; it gates only the UNSOLICITED popup, so the command palette's "What's new" action still opens it on request. `--config-dir` picks the directory the server exposes (projects AND the `permissions` policy its routes enforce -- every route since vNEXT, only `/auth/*` and `/merge-requests/*` on 0.98.0 and older); *(since 0.91.0)* an explicit root-level `kbagent --config-dir Y serve` is honored too, with the serve-level flag winning when both are given -- on 0.90.1 and older the root-level one was silently ignored, so pass it to `serve` itself there. Requires the optional `server` extra

## Global Flags
| Flag | Description |
Expand Down
Loading
Loading