KubeIntellect is an AI agent that runs kubectl against a live cluster, so its tools carry a role check and a human-approval gate. Both depend on one thing: the tool actually receiving the run config the graph injects. This is the parameter that receives it.
config: Annotated[RunnableConfig, InjectedToolArg] = None, # type: ignore[assignment]
That line is wrong in every way a reviewer is trained to notice. The default is None, but the annotation does not say | None. mypy complains, which is why there is a type: ignore sitting on it. Ruff wants to rewrite the Optional[...] spelling of it. Every instinct says clean this up.
Cleaning it up disables role-based access control.
Why the annotation is load-bearing
LangChain finds the parameter to inject by walking the type hints and comparing with is:
for name, type_ in type_hints.items():
if type_ is RunnableConfig:
return name
return None
That is an identity comparison against one exact class object. RunnableConfig | None is a UnionType, not that object. It does not match, so no parameter is selected, so nothing is injected.
Three functions, run against langchain-core 1.6.2:
def bare(cmd: str, config: Annotated[RunnableConfig, InjectedToolArg] = None): ...
def widened(cmd: str, config: Annotated[RunnableConfig | None, InjectedToolArg] = None): ...
def optional_form(cmd: str, config: Annotated[Optional[RunnableConfig], InjectedToolArg] = None): ...
bare -> injected param: 'config'
widened -> injected param: None
optional_form -> injected param: None
No error. No warning. The tool still runs. It just receives config=None forever.
What is actually lost
The caller's role comes off that config, and the fallback is the problem:
user_role = "admin"
if config:
user_role = (config.get("configurable") or {}).get("user_role", "admin")
With the config gone, config is falsy and every call executes as admin. A readonly API key stops being read-only. The check that returns [Permission Denied] Your API key has read-only access is still there, still covered by its unit tests, still passing — and it never fires, because the role it compares against is no longer the caller's role.
To be precise about the other half, since overstating this would be easy: the human-approval gate does not break the same way. hitl_bypass also comes off the config and defaults to False, so losing the config makes the tool prompt for approval more often, not less. The RBAC default is the one that fails open.
The part that makes it a trap rather than a bug
Nobody has to be careless to introduce this. The tooling volunteers it.
$ ruff check --select UP045 probe.py
UP045 [*] Use `X | None` for type annotations
--> probe.py:5:35
help: Convert to `X | None`
[*] 1 fixable with the `--fix` option.
The [*] means ruff classifies that rewrite as a safe fix. Not --unsafe-fixes. Plain ruff check --fix — the command people run without reading the diff — converts a working authorization boundary into a no-op.
A behavioral test will not save you either. The tools most likely to get "cleaned up" are the ones that never read config at all: they pass every test they have while silently receiving None. We found exactly that shape in four of our own read verbs. Harmless there, because those verbs make no authorization decision — but it is the same defect, one file away from a place where it matters.
What we did instead of a comment
There is a comment on the line. Comments do not fail CI.
The guard is a test that asserts the annotation itself. It scans every config: Annotated[..., InjectedToolArg] parameter in the source and fails if any is not bare RunnableConfig. Alongside it are two dynamic canaries that build a real tool with each spelling and assert, against whatever langchain version is actually installed, that the required form is injected and the forbidden form is not — so the day the library changes its matching rule, a test says so instead of production.
There is also a test asserting the scanner finds the known sites, because a regex that matched nothing would make every other assertion in the file vacuously true.
The general version
When a framework dispatches on type identity, your annotation is not documentation. It is runtime configuration written in the type language — and anything that "improves" your types can change behavior: a linter, a type checker, an IDE quick-fix, or an agent asked to clean up implicit Optional.
If you have a line like that, the fix is not a louder comment. It is a test that fails when someone improves it.
Code: https://github.com/MSKazemi/kubeintellect
The guard: v4/tests/test_injected_config_invariant.py
Top comments (3)
That
ischeck is what makes the "safe" autofix a trap. It's not a type annotation anymore, it's a marker that LangChain matches by identity, andRunnableConfig | Noneis a different object entirely.What gets me is the failure mode: no exception, no log, just RBAC quietly gone while the tool keeps running. I'd want a test that calls each tool and asserts the injected config is not None, because the type checker will keep trying to push you toward the union and a reviewer will too.
Right — that's basically what the guard does, just static instead of dynamic: it scans the app source for every
config: Annotated[..., InjectedToolArg]site and fails if any isn't the bare form, so new tools get covered automatically without having to invoke each one. The canary pair (bare vs Optional) is there to prove why, against whatever langchain-core is actually installed, in case the identity-matching rule ever changes. Appreciate you naming the failure mode exactly — no exception, tool keeps running, that's what made a comment feel insufficient here.An agent with kubectl on a live cluster can also create things that bill, and the mistakes that show up on the invoice later are usually load balancers and volumes rather than anything that breaks
Curious whether the role scopes limit which resource types the agent is allowed to create