Skip to content
Draft
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
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,18 @@ kbagent token refresh --project NAME --token-id ID [--yes]
# like cli:read, cli:write, cli:destructive). `tool:*` patterns are INERT since 0.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. The agent guards rails against mistakes; not a sandbox.
# (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.90.1 and older only /auth/* was checked and every other route
# executed unchecked. 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. Coarser than the
# CLI where a path param collapses leaves: POST /semantic-layer/items/{kind} maps to the
# parent key `semantic-layer.add`, so a leaf-only policy pattern is CLI-only.
# `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`.

**231 operations** across **202 paths** and **30 routers**.
**232 operations** across **203 paths** and **31 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 @@ -458,6 +458,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
78 changes: 66 additions & 12 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,12 +484,11 @@ 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
Expand Down Expand Up @@ -572,6 +570,62 @@ 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/*` only; 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.

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.

**Granularity caveat.** A few routes are coarser than the CLI operation they
mirror. `POST /semantic-layer/items/{kind}` covers `metric`/`dataset`/… in one
route, so it maps to the collapsed parent key `semantic-layer.add`, not
`semantic-layer.add.metric`. A policy naming only a leaf key is enforced on the
CLI but not over REST — name the parent, or a `cli:*` category, to cover both.

**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 @@ -92,6 +92,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.90.1 and older only `/auth/*` was checked. `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
- `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
Expand Down
48 changes: 34 additions & 14 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -4506,17 +4506,37 @@ shapes.
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.
- **A deny policy firewalls the WHOLE REST surface (since vNEXT, #655).**
Every route is classified in `server/route_permissions.py` and checked by
one app-level dependency against the served config dir's policy, so
`permissions set --mode deny --deny cli:write` now blocks `POST
/storage/tables/{project}` exactly like it blocks `POST
/auth/register-projects` -- HTTP 403, `error_code: PERMISSION_DENIED`, same
code the CLI exits on. **On 0.90.1 and older only `/auth/*` was checked**;
on those versions every other write/destructive route executed unchecked, so
do not rely on a deny policy to contain a `serve` you did not upgrade.
Bootstrap paths (`/health/ping`, `/health/auth-info`, `/ui-config`, `/docs`,
`/redoc`, `/openapi.json`, SPA shell) are never checked, by design.
- **`GET /permissions/show` (since vNEXT)** reports the EFFECTIVE policy --
the persisted block already merged with the `--deny-*` flags the daemon was
launched with. Read-only: there is no REST way to change the policy, so an
agent cannot widen the firewall that constrains it. Reachable under any
policy (`permissions.*` is always allowed), but still needs the bearer token.
- **REST classification is coarser than the CLI for `semantic-layer` sub-apps.**
`POST /semantic-layer/items/{kind}` maps to the collapsed parent key
`semantic-layer.add`, not `semantic-layer.add.metric` (`kind` is a path
param). A policy naming only the leaf key is enforced on the CLI but not
over REST -- name the parent or a `cli:*` category to cover both.
- Three registry keys back a serve-only surface with no CLI leaf command:
`auth.projects` (the terminal equivalent is `auth register-projects`'s
interactive picker) plus `ai.chat` and `workspace.sql-improve` (since vNEXT --
the dashboard's Local AI tile and the SQL editor's "improve this query"
helper; both are `write`, because both spawn a local CLI process on the host
exactly like `agent prompt-improve`). All three are 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 before
adding another.
- A route with no entry in `ROUTE_OPERATIONS` is **refused** (403), not
exempted. A released build cannot reach that path -- a test asserts the
table matches the live app in both directions -- so seeing it means a route
was added without classifying it.
5 changes: 4 additions & 1 deletion src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1917,7 +1917,10 @@
List all operations with risk categories and current allowed/denied status.

kbagent permissions show
Show current active permission policy.
Show current active permission policy. The persisted policy also applies
to every route of `kbagent serve` (denial = HTTP 403 PERMISSION_DENIED),
against the config dir the SERVER resolved; `GET /permissions/show` over
serve reports that effective policy. No REST route can change it.

kbagent permissions set --mode allow|deny [--allow PATTERN ...] [--deny PATTERN ...]
Set firewall-style permission policy. Patterns: exact (branch.delete),
Expand Down
37 changes: 35 additions & 2 deletions src/keboola_agent_cli/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,19 @@
"semantic-layer.reference-data.get": "read",
"semantic-layer.reference-data.set": "write",
"semantic-layer.reference-data.delete": "destructive",
# Serve-only AI helpers (since vNEXT). Neither touches Keboola, but both
# spawn a local `claude` / `codex` / `gemini` process on the host, exactly
# like `agent prompt-improve` -- classified `write` for the same reason, so
# one --deny-writes keeps an agent from spawning subprocesses through any
# of the three. Exempted from the dead-key check via SERVE_ONLY_OPERATIONS.
"ai.chat": "write",
"workspace.sql-improve": "write",
# Raw HTTP client against `kbagent serve` (used by AI subprocesses).
# Categorised by the underlying HTTP method: GET = read, mutating verbs
# = write. The serve's own routes enforce their own permissions on top.
# = write. Since vNEXT (issue #655) the serve's own routes DO enforce the
# served config dir's policy on top, so a denied operation is refused at
# both ends; before that, this second layer did not exist and the claim
# this comment used to make was false.
"http.get": "read",
"http.post": "write",
"http.patch": "write",
Expand Down Expand Up @@ -393,7 +403,16 @@
# they have no CLI leaf command, so the command-sync gate would otherwise report
# them as dead keys -- `scripts/check_command_sync.py` subtracts this set before
# its "key matching no live command" check.
SERVE_ONLY_OPERATIONS: frozenset[str] = frozenset({"auth.projects"})
SERVE_ONLY_OPERATIONS: frozenset[str] = frozenset(
{
"auth.projects",
# Both back a web-UI affordance with no terminal equivalent: the
# dashboard's Local AI chat tile and the workspace SQL editor's
# "improve this query" helper.
"ai.chat",
"workspace.sql-improve",
}
)


# The operation namespace that disappeared with the MCP passthrough, and the
Expand Down Expand Up @@ -528,6 +547,20 @@ def active(self) -> bool:
"""Whether a permission policy is configured."""
return self._policy is not None

@property
def policy(self) -> PermissionPolicy | None:
"""The EFFECTIVE policy this engine evaluates, or None when unrestricted.

On the serve side the engine is built by ``create_app`` from the served
directory's persisted policy already merged with ``--deny-writes`` /
``--deny-destructive`` (:func:`apply_firewall_flags`), so this is what
``GET /permissions/show`` must report: a caller needs the rules that
actually apply, not the persisted half of them.

Read-only by design -- a policy is chosen once, at engine construction.
"""
return self._policy

def is_allowed(self, operation: str) -> bool:
"""Check if an operation is allowed by the active policy.

Expand Down
24 changes: 23 additions & 1 deletion src/keboola_agent_cli/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import secrets
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
Expand All @@ -32,6 +32,7 @@
from .agents_store import AgentStore
from .auth import PUBLIC_PATHS, AuthSettings, install_auth
from .dependencies import ServiceRegistry, install_permission_engine, install_registry
from .route_permissions import enforce_route_permission
from .routers import (
agents,
ai_chat,
Expand Down Expand Up @@ -64,6 +65,9 @@
transformation,
workspaces,
)
from .routers import (
permissions as permissions_router,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -357,6 +361,17 @@
),
},
# ---- System ----
{
"name": "permissions",
"description": (
"**System.** "
"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`."
),
},
{
"name": "health",
"description": (
Expand Down Expand Up @@ -674,6 +689,12 @@ async def _lifespan(app_: FastAPI):

app = FastAPI(
lifespan=_lifespan, # type: ignore[arg-type]
# The session firewall, applied to EVERY route the app declares
# (issue #655). App-level rather than per-router so a new router
# cannot be added outside it; the route -> operation classification
# lives in `route_permissions.ROUTE_OPERATIONS`, and an unclassified
# route is refused rather than silently exempted.
dependencies=[Depends(enforce_route_permission)],
title="kbagent serve",
description=APP_DESCRIPTION,
version=__version__,
Expand Down Expand Up @@ -780,6 +801,7 @@ async def _generic_handler(_request, exc: Exception):
return _format_error(str(exc) or repr(exc), ErrorCode.INTERNAL_ERROR, http_status=500)

app.include_router(health.router)
app.include_router(permissions_router.router)
app.include_router(auth.router)
app.include_router(projects.router)
app.include_router(members.router)
Expand Down
Loading