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

> Source: <https://dev.to/mskazemi/our-linters-safe-autofix-would-have-silently-disabled-rbac-log>
> Published: 2026-09-20 21:54:58+00:00

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:

``` python
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.

``` bash
$ 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](https://github.com/MSKazemi/kubeintellect)

The guard: `v4/tests/test_injected_config_invariant.py`
