Skip to content

serve: honor the root-level --config-dir, and fix two stale permissions comments - #681

Merged
padak merged 6 commits into
mainfrom
fix/http-comment-serve-enforcement
Aug 24, 2026
Merged

serve: honor the root-level --config-dir, and fix two stale permissions comments#681
padak merged 6 commits into
mainfrom
fix/http-comment-serve-enforcement

Conversation

@padak

@padak padak commented Aug 23, 2026

Copy link
Copy Markdown
Member

Closes #679.

1. kbagent serve ignored the root-level --config-dir

serve is the only subcommand in the repo that defines its own --config-dir, and it forwarded only that one into create_app. The directory the root callback had already resolved into ctx.obj["config_store"] was never read, and the callback exports no KBAGENT_CONFIG_DIR, so there was no indirect channel either. kbagent --config-dir X serve therefore served whatever the default chain found — silently, with no warning.

Two consequences, both reproduced live before the fix:

  • The wrong projects were served. kbagent --config-dir dirA serve answered /projects with the 51 aliases of the global config instead of dirA's single alpha-only.
  • The wrong directory's permission policy applied. Since serve: expose auth register-projects over REST (POST /auth/register-projects) #677 the serve permission engine is composed from the directory create_app resolves, so a persisted deny policy stored next to the projects the caller named was not read at all. In the end-to-end fixture, POST /auth/register-projects returned 401 SESSION_NOT_FOUND (policy never loaded) where it should have returned 403 PERMISSION_DENIED.

Fix — most-specific-wins precedence, matching the pattern commands/repl.py already uses when forwarding the root flag into a subcommand:

  1. serve --config-dir X given → serve X (unchanged; every existing invocation keeps working)
  2. otherwise, root --config-dir Y given explicitly → serve Y (this is the fix)
  3. otherwise → today's chain: KBAGENT_CONFIG_DIR.kbagent walk-up → global (unchanged)

"Given explicitly" is read off ConfigStore.source == "cli-flag" rather than by comparing or re-resolving paths. Only that source travels: for env-var / local / global, letting create_app resolve on its own yields the identical directory, and propagating them would pin a resolution made at a different moment. Deliberately no fail-fast and no warning when both flags are given and differ — that would break invocations that work today, and the precedence is unambiguous.

The serve --config-dir help text now states the precedence; its old wording ("matches kbagent --config-dir") is what made the bug plausible in the first place.

Verification

Live A/B against the real CLI with two fixture config dirs, plus 6 new tests in tests/test_server_permissions.py — five precedence cases (root-only, serve-only, both, neither, env-var-set-without-root-flag) asserting the config_dir kwarg create_app receives, and one end-to-end test that persists a deny policy in the served directory and asserts 403 PERMISSION_DENIED on POST /auth/register-projects while a sibling operation outside the policy is not denied. A negative control confirmed exactly the two new-behavior tests fail without the fix.

Invocation before after
--config-dir dirA serve global config (51 aliases) alpha-only
serve --config-dir dirA alpha-only alpha-only
both flags serve-level wins serve-level wins
neither fallback chain fallback chain

2. Two comment corrections in permissions.py

Found while filing #679; both are fallout of #677.

  • A (since vNEXT) placeholder shipped in 0.90.1. auth.projects still carried it on main, because scripts/check_version_gates.py scanned CLAUDE.md, docs/*.md and plugins/kbagent/**/*.md only — a placeholder anywhere under src/ was invisible to it and shipped unresolved. Pinned to 0.90.1, and the cause is fixed too (below).

3. The version gate now scans src/**/*.py

Added on review: pinning the placeholder alone would let the next feature PR ship the same defect, since writing (since vNEXT) in a Python comment is a pattern permissions.py itself establishes as normal. Verified the widened scan would have caught the original — run against git show 11724656:src/keboola_agent_cli/permissions.py, it flags line 29. Scanned files 67 → 295, gates 476 → 529, vNEXT residue unchanged at 7.

scripts/*.py stays out, deliberately: the gate script must name the token it hunts (VNEXT_TOKEN = "vNEXT", its own --release usage example), so scanning itself self-flags forever. The comment says so, to stop the next person retrying it.

INLINE_CODE_RE also learned RST double-backtick spans, since Python docstrings use them and a (since vNEXT) in one would otherwise false-positive. Measured: markdown residue identical at 7, so no live gate is lost. The double-backtick alternative must come first in the alternation — with the single-backtick one first, the pattern matches the empty span between the two opening backticks and leaves the token exposed.

The wider scan immediately found a real pre-existing defect: commands/project.py:839 carried (since v0.26.1), a version that was never released (git tag -l "v0.26*" → only v0.26.0; no such GitHub release; not a CHANGELOG key). Corrected to v0.29.0CHANGELOG["0.29.0"] describes those seven member/invitation commands verbatim, and the commit that added the line is c7d70788 feat(0.29.0): … + member lifecycle (#256), whose message still carries the pre-renumber (since v0.26.1) draft. Same failure mode as the 0.90.1 placeholder, three months older.

Docs

docs/web-server.md, CLAUDE.md and gotchas.md carried #677's stopgap advice ("pass --config-dir to serve itself"), which describes the bug rather than the intended behavior — rewritten to the fixed precedence, with the old trap kept intact for readers on 0.90.1. commands-reference.md and context.py's AGENT_CONTEXT are on CONTRIBUTING's synchronization map for flag-behavior changes and were updated too. New behavior is gated with the literal (since vNEXT); context.py deliberately carries no version tag, since a placeholder in a .py file is exactly what the gate cannot resolve.

Testing

make check green: 6123 passed, 12 skipped. ty clean for every touched file (one pre-existing unrelated diagnostic in scripts/hatch_build.py remains). check_command_sync.py, check_version_gates.py, ruff check + format all OK. The version-gate tests grew by 9, mutation-checked to fail against the pre-change state. No version bump, no changelog entry.

…nt claim

Two corrections in OPERATION_REGISTRY's comments, both fallout of #677:

- `auth.projects` still carried `(since vNEXT)` after the 0.90.1 release.
  The release-time gate (scripts/check_version_gates.py) only scans markdown
  surfaces -- CLAUDE.md, docs/*.md, plugins/**/*.md -- so a placeholder in
  src/ resolves nowhere and ships silently. Pinned to 0.90.1, the release
  that actually carries the endpoint.

- The `http.*` block claimed "the serve's own routes enforce their own
  permissions on top". Issue #655 flagged that as load-bearing and false:
  it was true for no route at all. Since 0.90.1 it is true for /auth/* and
  nothing else, so the comment now states that scope instead of a blanket
  claim that misleads the risk classification directly beneath it.
@padak
padak requested a review from zajca August 23, 2026 23:46

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@zajca zajca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Selected findings from the automated read-only review.

Comment thread src/keboola_agent_cli/permissions.py
Comment thread src/keboola_agent_cli/permissions.py Outdated
padak added 2 commits August 24, 2026 02:28
`serve` is the only subcommand carrying a `--config-dir` of its own, and it
forwarded only that one into `create_app`. The root-level `kbagent
--config-dir A serve` was ignored entirely, with no warning: the server
exposed whatever the fallback chain resolved (usually the global config)
instead of the directory the caller named. Since 0.90.1, when the `/auth/*`
routes began enforcing the persisted `permissions` policy, that also meant a
deny policy stored beside the caller's projects was silently not the one in
force -- verified live: `POST /auth/register-projects` answered 401 (the other
directory's session state) where the named directory's policy says 403.

Precedence is now most-specific-wins, the same rule `kbagent repl` applies
when forwarding root flags into a subcommand: `serve --config-dir X` beats a
root `kbagent --config-dir Y`, which beats KBAGENT_CONFIG_DIR / the .kbagent
walk-up / global. Passing both is not an error. Only an explicit `cli-flag`
source propagates -- for env-var/local/global, `create_app` resolves the
identical directory itself, and forwarding those would pin a resolution made
at a different moment.

Tests cover all four flag combinations plus the env-var non-propagation, and
one end-to-end case proving a deny policy in the root-flag directory reaches
the REST surface. Docs updated on docs/web-server.md, CLAUDE.md, gotchas.md,
commands-reference.md and the `kbagent context` text; the new behavior is
gated `(since vNEXT)` for the release PR to resolve.
The comment justifying the `!= 403` assertion said "there is no session in
this fixture dir", implying a SESSION_NOT_FOUND / 401 path. Verified: the
response is HTTP 400 CONFIG_ERROR -- the fixture dir has no default project
and the request carries no `?stack=`, so `AuthService.status()` ->
`_resolve_stack_url()` raises ConfigError before any session lookup happens.

Comment rewritten to name that mechanism and to lead with the point it is
there to make: `/auth/status` is gated by a different registry key
(`auth.status`), which the deny policy does not name, so a non-403 proves the
denial is narrow rather than a blanket failure of the whole REST surface. The
assertion is unchanged -- it was correct.
@padak padak changed the title permissions: fix a leftover vNEXT placeholder and the false serve-enforcement comment serve: honor the root-level --config-dir, and fix two stale permissions comments Aug 24, 2026
padak added 3 commits August 24, 2026 02:47
The previous commit rewrote a `(since vNEXT)` that survived the 0.90.1
release, but not the reason it survived: check_version_gates.py resolved
its file list from SCANNED_GLOBS, which listed markdown plus exactly one
hand-picked Python file (commands/context.py). permissions.py was never
read, so the placeholder was invisible to the very check built to catch
it -- and the next feature PR writing a gate in a Python comment would
have shipped the same defect. Fixing only the symptom leaves the hole.

A version gate in a Python comment is agent-facing documentation exactly
like a markdown one, so src/**/*.py is now scanned as a whole (it
subsumes the context.py entry). Verified against the 0.90.1 tree: the
widened scan reports permissions.py:29, the marker that shipped.

Its first run on the current tree also surfaced a real, pre-existing
defect -- `(since v0.26.1)` on the project members & invitations block.
0.26.1 never existed: it is not a CHANGELOG key, `git tag -l "v0.26*"`
lists only v0.26.0, and `gh release view v0.26.1` reports no release.
The feature shipped in 0.29.0 (CHANGELOG 0.29.0 describes the seven
`project` member/invitation commands; the commit that added this very
block is c7d7078 "feat(0.29.0): ... + member lifecycle", drafted as
0.26.1 and renumbered -- the docs were rewritten at release, the Python
comment was not). Corrected to v0.29.0.

scripts/*.py is deliberately left out and the constant now says why:
this module has to NAME the placeholder it hunts (its --release usage
line, the VNEXT_TOKEN constant), so scanning it self-flags forever.

INLINE_CODE_RE also learns double-backtick spans. Python docstrings here
use RST convention, so the first ``(since vNEXT)`` written in one would
otherwise be a false positive. Measured: markdown residue is unchanged
at 7, i.e. no live gate is lost to over-stripping. The double-backtick
alternative comes first because alternation is left-biased -- the
single-backtick pattern would match the empty span between the two
opening backticks and leave the token exposed.

Gate counts: 67 files / 476 markers -> 295 files / 529 markers.
…stry

The block opened with "GET = read, mutating verbs = write" and then
classified `http.delete` as destructive three lines below it. DELETE is
a mutating verb, so a reader auditing the four keys against the stated
rule hits a contradiction on the strictest one -- and this comment is
the security rationale those keys are audited against.

State the mapping that the registry actually implements, in the taxonomy
declared at the top of the file (read = no side effects, write =
creates/modifies, destructive = deletes): GET = read, POST/PATCH =
write, DELETE = destructive. The extra rung is load-bearing, not
cosmetic: apply_firewall_flags adds `cli:destructive`, which matches
only the destructive class, so classifying http.delete as `write` would
let a --deny-destructive session delete through the REST boundary.
The Windows job failed on the new test with UnicodeEncodeError: cp1252 is
the platform default there, and the fixture mirrors a real repo line whose
section rule is box-drawing characters. Reproduced locally --
write_text(body, encoding='cp1252') raises exactly the CI error, and the
same bytes read back as cp1252 turn '--' rules into mojibake.

The read side matters beyond the test. Widening SCANNED_GLOBS to
src/**/*.py put 73 non-ASCII-carrying source files in front of a
read_text() that never named an encoding, so on Windows the scan would
regex over mangled text -- and since INLINE_CODE_RE keys on backticks,
mojibake can move or destroy the very characters that separate a live
placeholder from prose quoting one. A gate that classifies differently per
OS is worse than no gate.

Both read_text() calls in the script and every read/write in its tests now
name UTF-8 explicitly.
@padak
padak merged commit a1f85f2 into main Aug 24, 2026
3 checks passed
@padak
padak deleted the fix/http-comment-serve-enforcement branch August 24, 2026 06:21
padak added a commit that referenced this pull request Aug 25, 2026
Moves the (since vNEXT) tags from three section headings -- the two new
ignored-components sections plus the pre-existing serve --config-dir
gotcha from #681 -- to the sections' first body line, so the heading
anchor slugs stay stable when the placeholder resolves at release.
padak added a commit that referenced this pull request Aug 25, 2026
Moves the (since vNEXT) tags from three section headings -- the two new
ignored-components sections plus the pre-existing serve --config-dir
gotcha from #681 -- to the sections' first body line, so the heading
anchor slugs stay stable when the placeholder resolves at release.
padak added a commit that referenced this pull request Aug 25, 2026
Moves the (since vNEXT) tags from three section headings -- the two new
ignored-components sections plus the pre-existing serve --config-dir
gotcha from #681 -- to the sections' first body line, so the heading
anchor slugs stay stable when the placeholder resolves at release.
@padak padak mentioned this pull request Aug 25, 2026
13 tasks
padak added a commit that referenced this pull request Aug 25, 2026
Bump pyproject to 0.91.0, add the changelog entry covering every PR merged since v0.90.1 (#627, #681, #691, #692, #693, #694, #695, #696, #697, #698), resolve all 54 vNEXT version-gate placeholders, and run version-sync + skill-gen.

No web/frontend changes in this batch, so no whatsnew.ts entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serve: root-level --config-dir is silently ignored (kbagent --config-dir X serve serves a different dir)

2 participants