cd /news/ai-agents/our-linter-s-safe-autofix-would-have… · home topics ai-agents article
[ARTICLE · art-135385] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↓ negative

Our linter's "safe" autofix would have silently disabled RBAC

A developer found that LangChain's RunnableConfig injection relies on an identity comparison against the exact RunnableConfig class, so widening the annotation to RunnableConfig | None or Optional[RunnableConfig] silently prevents injection and leaves tools receiving config=None. Because the affected KubeIntellect agent derives the caller's role from that config and defaults to "admin" when it is missing, the failure disables role-based access control while the permission check and its unit tests still pass. The developer notes that Ruff classifies the UP045 rewrite to X | None as a safe autofix, and added a source-scanning test plus dynamic canaries to guard the annotation.

by read4 min views1 publishedSep 20, 2026

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.

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): ...
php
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.

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.

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.

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.

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

── more in #ai-agents 4 stories · sorted by recency
── more on @langchain 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/our-linter-s-safe-au…] indexed:0 read:4min 2026-09-20 ·