feat(server): authenticate in-cluster webhook receivers via K8s ServiceAccount identity - #9
Open
btli wants to merge 9 commits into
Open
feat(server): authenticate in-cluster webhook receivers via K8s ServiceAccount identity#9btli wants to merge 9 commits into
btli wants to merge 9 commits into
Conversation
…e sub-agents Named sub-agent workers on the kimi-native and antigravity-native harnesses launched with no autonomy flag, so every risky tool call parked on a web approval card no headless pane can answer. _derive_terminal_launch_args_from_spec only knew claude/codex/cursor and fell through to None for both harnesses. - kimi-native: executor.config yolo: true -> ["--yolo"] (kimi's auto-approve-tools flag, matching codex/cursor semantics; --auto full autonomy deliberately not mapped). Opt-in: absent/false unchanged. - antigravity-native: executor.config permission_mode: bypassPermissions -> ["--dangerously-skip-permissions"], agy's only pre-emptive permission control. Other/absent modes unchanged. The runner spawn path already forwards snapshot terminal_launch_args verbatim into the agy argv (build_agy_launch extra_args), now pinned by a spawn-path test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
Tribunal round-1 findings on the kimi-native / antigravity-native launch-arg derivation: - Verified the spec parser stringifies scalar executor.config values (spec/parser.py str(v) coercion), so the bool arm serves programmatically built specs (config is dict[str, Any]); kept it, aligned the comment, and added bool True/False test rows. - Documented the value-matching policy: flag keys (yolo) accept bool or case-insensitive true/false strings (mirroring _spec_config_flag_explicitly_disabled); mode keys (permission_mode) match exactly, mirroring the runner's should_skip_permissions comparison. - Debug-log a present-but-unrecognized yolo / permission_mode value instead of silently no-opping. - Parametrized boundary tests pinning accepted-vs-rejected spellings for both branches. - Pinned build_agy_launch's existing skip-flag dedup for the double-source case (permission_mode=bypassPermissions + the flag already in extra_args -> exactly one flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Bryan Li <bryan.li@gmail.com>
fix(server): derive launch args for kimi-native and antigravity-native sub-agents
…ceAccount identity Lets a workload authenticated as its own Kubernetes ServiceAccount (e.g. the k3s-infra webhook receiver) call this server directly instead of via a human `omnigent login` + manually-refreshed 30-day JWT. Two additive, default-off changes: - server/auth.py: UnifiedAuthProvider._check_cookie's Bearer fallback, on HS256 decode failure, now also tries verifying the token as a K8s SA JWT (reusing the existing PyJWKClient pattern from routes/auth.py's _resolve_oidc_email), pinning issuer + audience + an exact subject allowlist. Gated behind OMNIGENT_K8S_SA_AUTH_ENABLED; unconfigured, the branch is unreachable and a Bearer token that fails the primary decode is rejected exactly as before. A human/CLI session token always succeeds on the first HS256 decode, so it never reaches this path. - server/routes/_host_launch.py: resolve_host_owner gains an exact-match (host_id, identity) carve-out (OMNIGENT_HOST_LAUNCH_ALLOWLIST) so an allowlisted non-owner identity may launch a runner on one specific host without taking over its ownership. Empty/unset allowlist reproduces today's owner-only 403 behavior exactly. Env config introduced (all optional, all default-off): OMNIGENT_K8S_SA_AUTH_ENABLED, OMNIGENT_K8S_SA_ISSUER, OMNIGENT_K8S_SA_AUDIENCE, OMNIGENT_K8S_SA_SUBJECTS, OMNIGENT_K8S_SA_JWKS_URI, OMNIGENT_K8S_SA_CA_BUNDLE, OMNIGENT_HOST_LAUNCH_ALLOWLIST. Server-side only — no receiver/k8s/deployment/manifest changes (PR-B). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related issue
Summary
Lets the in-cluster webhook receiver authenticate to this server with its own
Kubernetes ServiceAccount identity instead of a human-minted
omnigent login+manually-refreshed 30-day JWT. Server-side only; no receiver/k8s/deployment
files are touched here.
server/auth.py—UnifiedAuthProvider._check_cookie'sAuthorization: Bearerfallback, on HS256 session-decode failure, now alsotries verifying the token as a Kubernetes ServiceAccount JWT (new
_check_k8s_service_account), reusing the existingjwt.PyJWKClientverification pattern already used by
routes/auth.py's_resolve_oidc_email. On success, the token'ssubclaim maps touser_id. All three of issuer, audience, and an exact subjectallowlist are pinned before a token is accepted.
server/routes/_host_launch.py—resolve_host_ownergains anexact-match
(host_id, identity)carve-out (newresolve_host_launch_allowlist) so an allowlisted non-owner identity maylaunch a runner on one specific host without becoming that host's owner —
the maintainer's existing ownership of
server1is untouched.Default-off
Both behaviors are inert unless explicitly configured:
OMNIGENT_K8S_SA_AUTH_ENABLEDistruthy. Unset (the default),
_check_k8s_service_accountreturnsNoneimmediately and a Bearer token that fails the primary decode is rejected
exactly as it is today. Once enabled, the issuer/audience/subjects vars are
all required — a half-configured deployment fails loud at startup
(
RuntimeError), the same postureOIDCConfig.from_env/AccountsConfig.from_envalready use for their own required vars.403 behavior exactly. A malformed
OMNIGENT_HOST_LAUNCH_ALLOWLISTentryfails closed to an empty allowlist (logged, not silently partially
applied), so a typo can only ever narrow access back to owner-only, never
widen it.
New env config (all optional)
OMNIGENT_K8S_SA_AUTH_ENABLEDOMNIGENT_K8S_SA_ISSUERiss, e.g.https://kubernetes.default.svc.cluster.localOMNIGENT_K8S_SA_AUDIENCEaud, e.g.omnigent-webhook-receiverOMNIGENT_K8S_SA_SUBJECTSsuballowlist, e.g.system:serviceaccount:webhooks:webhookOMNIGENT_K8S_SA_JWKS_URI<issuer>/openid/v1/jwksOMNIGENT_K8S_SA_CA_BUNDLE/var/run/secrets/kubernetes.io/serviceaccount/ca.crtwhen present, else the interpreter's default trust storeOMNIGENT_HOST_LAUNCH_ALLOWLISThost_id=identity[,host_id=identity...]map, e.g.server1=system:serviceaccount:webhooks:webhookSecurity invariants
_check_k8s_service_accountverifiessignature,
iss, andaudtogether in onejwt.decodecall, thenadditionally requires the decoded
subto exact-match the configuredallowlist (and rejects the reserved
local/__public__names even ifsomeone misconfigures them into the allowlist). A valid signature alone
only proves the token came from some ServiceAccount in the cluster —
the allowlist is what scopes it to the one specific identity this
deployment trusts. Verification failure of any kind (bad signature,
wrong issuer/audience, unlisted subject, expired, malformed, or a
JWKS-fetch error) returns
Noneand falls through to the ordinary 401 —no claim contents are logged.
resolve_host_ownerdoes a plain(host_id, identity) in allowlistset-membership check — noprefix/substring/wildcard matching anywhere in the parse or lookup path.
Covered by tests asserting a near-miss identity (
...-imposter) and awrong-but-allowlisted-elsewhere
host_idboth still 403.Human-login-unchanged proof
_check_cookietries the self-minted HS256 decode first; only a Bearertoken that fails that decode is ever handed to
_check_k8s_service_account.test_human_session_token_unaffected_by_k8s_branchproves this directly: it monkeypatches
PyJWKClient.get_signing_key_from_jwtto raise
AssertionErrorif ever called, then verifies a valid human/CLIsession cookie still authenticates successfully — i.e. the SA verification
path is provably never reached for a human token, not just untested.
Test Plan
New tests (
tests/server/test_k8s_sa_auth.py, 22 tests) cover, usinggenuinely RS256-signed JWTs verified through the real
jwt.decodecall(only the JWKS network fetch is stubbed, same boundary the existing
test_oidc_callback.pystubs):user_id == sub(prefix) subject / expired / malformed token — all rejected
local) rejected even if present in the allowlistk8s_sa_config) → inert, immediateNoneget_user_id: SA bearer accepted, garbage bearer rejectedwhen disabled, human HS256 session token unaffected (see above)
resolve_k8s_sa_auth_config(): disabled by default, explicit0,fail-loud on each missing required var, full config builds correctly,
JWKS URI override, CA bundle override builds a real
ssl.SSLContext,comma-separated multi-subject parsing
Extended tests (
tests/server/routes/test_host_launch.py, +18 tests) cover:resolve_host_launch_allowlist()parsing: unset, blank, single/multientry, whitespace tolerance, and three distinct malformed-entry shapes
all failing closed to an empty set
Gates run
uv run --no-sync pytest tests/server/— 3515 passed, 9 failed(pre-existing, unrelated), 1 skipped, 3 xfailed in
tests/server/routes/test_sessions_snapshot.py(session/model-catalog snapshot tests with zero references to
auth.py/_host_launch.py; all 41 tests in that file pass when run inisolation — confirmed test-order/pollution flakes in the pre-existing
suite, not a regression from this change)
uv run --no-sync ruff check .— clean on all changed filesuv run --no-sync ruff format --check .— clean on all changed filesuv run --no-sync pyrefly check— 0 errorsMutation-check evidence
For every new/changed test, reverting just the two source files to their
pre-fix state (
git checkout bb1db0233 -- omnigent/server/auth.py omnigent/server/routes/_host_launch.py, keeping the tests as committed)makes the entire new/changed test modules fail collection —
ImportErrorfor the exact new symbols (
K8sServiceAccountAuthConfig,resolve_host_launch_allowlist) the fix introduces, since these arebrand-new code paths with no prior behavior to diff against:
Restoring the fix and re-running the identical command turns every one of
those into a pass:
The same revert/restore was also run against the full new/changed test
modules (44 tests total) before narrowing to the 5-test excerpt above:
collection-error RED with the fix reverted,
44 passedGREEN with itrestored.
Demo
N/A — server-only auth/authz change, no UI.
Type of change
Test coverage
Coverage notes
Changelog
In-cluster workloads (e.g. a webhook receiver) can now authenticate with a Kubernetes ServiceAccount token instead of a human-minted session JWT (opt-in, default-off)