{"slug": "our-linter-s-safe-autofix-would-have-silently-disabled-rbac", "title": "Our linter's \"safe\" autofix would have silently disabled RBAC", "summary": "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.", "body_md": "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.\n\n```\nconfig: Annotated[RunnableConfig, InjectedToolArg] = None,  # type: ignore[assignment]\n```\n\nThat 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.\n\nCleaning it up disables role-based access control.\n\nLangChain finds the parameter to inject by walking the type hints and comparing with `is`:\n\n```\nfor name, type_ in type_hints.items():\n    if type_ is RunnableConfig:\n        return name\nreturn None\n```\n\nThat 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.\n\nThree functions, run against langchain-core 1.6.2:\n\n``` python\ndef bare(cmd: str, config: Annotated[RunnableConfig, InjectedToolArg] = None): ...\ndef widened(cmd: str, config: Annotated[RunnableConfig | None, InjectedToolArg] = None): ...\ndef optional_form(cmd: str, config: Annotated[Optional[RunnableConfig], InjectedToolArg] = None): ...\nphp\nbare           -> injected param: 'config'\nwidened        -> injected param: None\noptional_form  -> injected param: None\n```\n\nNo error. No warning. The tool still runs. It just receives `config=None` forever.\n\nThe caller's role comes off that config, and the fallback is the problem:\n\n```\nuser_role = \"admin\"\nif config:\n    user_role = (config.get(\"configurable\") or {}).get(\"user_role\", \"admin\")\n```\n\nWith 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.\n\nTo 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.\n\nNobody has to be careless to introduce this. The tooling volunteers it.\n\n``` bash\n$ ruff check --select UP045 probe.py\nUP045 [*] Use `X | None` for type annotations\n --> probe.py:5:35\nhelp: Convert to `X | None`\n[*] 1 fixable with the `--fix` option.\n```\n\nThe `[*]` 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.\n\nA 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.\n\nThere is a comment on the line. Comments do not fail CI.\n\nThe 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.\n\nThere 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.\n\nWhen 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`.\n\nIf you have a line like that, the fix is not a louder comment. It is a test that fails when someone improves it.\n\nCode: [https://github.com/MSKazemi/kubeintellect](https://github.com/MSKazemi/kubeintellect)\n\nThe guard: `v4/tests/test_injected_config_invariant.py`", "url": "https://wpnews.pro/news/our-linter-s-safe-autofix-would-have-silently-disabled-rbac", "canonical_source": "https://dev.to/mskazemi/our-linters-safe-autofix-would-have-silently-disabled-rbac-log", "published_at": "2026-09-20 21:54:58+00:00", "updated_at": "2026-09-20 22:24:20.925737+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools", "ai-safety"], "entities": ["LangChain", "KubeIntellect", "Ruff", "mypy", "langchain-core", "RunnableConfig", "InjectedToolArg"], "alternates": {"html": "https://wpnews.pro/news/our-linter-s-safe-autofix-would-have-silently-disabled-rbac", "markdown": "https://wpnews.pro/news/our-linter-s-safe-autofix-would-have-silently-disabled-rbac.md", "text": "https://wpnews.pro/news/our-linter-s-safe-autofix-would-have-silently-disabled-rbac.txt", "jsonld": "https://wpnews.pro/news/our-linter-s-safe-autofix-would-have-silently-disabled-rbac.jsonld"}}