<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Indra Gusti Prasetya</title>
    <description>The latest articles on DEV Community by Indra Gusti Prasetya (@indra_gustiprasetya_a80a).</description>
    <link>https://dev.to/indra_gustiprasetya_a80a</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3971045%2F76516018-d46d-403b-9d79-239ac1d80baa.png</url>
      <title>DEV Community: Indra Gusti Prasetya</title>
      <link>https://dev.to/indra_gustiprasetya_a80a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/indra_gustiprasetya_a80a"/>
    <language>en</language>
    <item>
      <title>pnpm readPackage Edits Leak to Deps You Never Matched</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Fri, 18 Sep 2026 02:33:45 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/pnpm-readpackage-edits-leak-to-deps-you-never-matched-bna</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/pnpm-readpackage-edits-leak-to-deps-you-never-matched-bna</guid>
      <description>&lt;p&gt;Until pnpm 11.27.0, the resolver kept one manifest object per resolved &lt;code&gt;name@version&lt;/code&gt; in its metadata cache and passed that object straight into your &lt;code&gt;readPackage&lt;/code&gt; hook. The documented pnpmfile example tells you to edit it in place and return it. Following the documentation wrote into the resolver's cache, and the next dependent that resolved to the same version inherited your edit whether your condition matched it or not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; pnpm PR &lt;a href="https://github.com/pnpm/pnpm/pull/14014" rel="noopener noreferrer"&gt;#14014&lt;/a&gt;, merged 9 September 2026 and shipped in &lt;a href="https://github.com/pnpm/pnpm/releases/tag/v11.27.0" rel="noopener noreferrer"&gt;11.27.0&lt;/a&gt;, adds a shallow copy of the manifest before the hook sees it. If you run a &lt;code&gt;.pnpmfile.cjs&lt;/code&gt; or &lt;code&gt;.pnpmfile.mjs&lt;/code&gt; that assigns to &lt;code&gt;pkg.*&lt;/code&gt;, upgrade, then rewrite the hook to return a new object instead of mutating the argument. The copy covers dependency and peer fields only, so edits to &lt;code&gt;pkg.scripts&lt;/code&gt;, &lt;code&gt;pkg.bin&lt;/code&gt; or &lt;code&gt;pkg.engines&lt;/code&gt; are still writes into an object you do not own.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  R["resolver metadata cache\none manifest object per name@version"] --&amp;gt; M["manifest for baz@1.0.0"]
  M --&amp;gt;|"passed by reference"| H1["readPackage runs for dependent A"]
  H1 --&amp;gt;|"pkg.dependencies.qux = 2.0.0"| M
  M --&amp;gt;|"same object, already edited"| H2["readPackage runs for dependent B"]
  H2 --&amp;gt; L["pnpm-lock.yaml records A's edit under B"]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  One object, many dependents
&lt;/h2&gt;

&lt;p&gt;The aliasing lives in &lt;code&gt;resolveDependencies.ts&lt;/code&gt;. When several dependents resolve to the same package version, pnpm hands each of them the identical manifest object from its metadata cache. Your hook mutation is never scoped to the dependent being processed; it lands in cache and stays there for the rest of the install.&lt;/p&gt;

&lt;p&gt;The fix is a helper named &lt;code&gt;copyResolvedManifest&lt;/code&gt;, added in &lt;code&gt;pnpm11/installing/deps-resolver/src/resolveDependencies.ts&lt;/code&gt; and applied before the manifest reaches &lt;code&gt;readPackageHook&lt;/code&gt;. That path matters. The change landed in the v11 tree of the monorepo, so anyone who has already moved to the 12.x Rust CLI should confirm the equivalent rather than assume it carried over. The pnpm 12 migration has enough surface of its own that assumption is expensive, as the &lt;a href="///blog/pnpm-12-upgrade-fix-12-rust-cli-traps-before-ci-breaks.html"&gt;twelve traps worth checking before a pnpm 12 CI upgrade&lt;/a&gt; covers. The reproduction at the end of this piece answers the question on any version without reading source.&lt;/p&gt;

&lt;p&gt;This sits underneath a practice most platform teams now depend on. &lt;code&gt;readPackage&lt;/code&gt; and &lt;code&gt;packageExtensions&lt;/code&gt; are how you pin a transitive dependency away from a bad release, repair a missing peer, or force a resolution upstream will not ship. Those are supply-chain controls written as JavaScript, and they were running against shared mutable state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 11.27.0 release note leaves out
&lt;/h2&gt;

&lt;p&gt;The release note describes the bug as a hook that "no longer changes what a later install in the same command resolves." The changeset attached to the PR is broader and more accurate: "Fixed manifest edits leaking between dependencies that resolve to the same package version."&lt;/p&gt;

&lt;p&gt;Take the second framing seriously, because it removes the precondition the first one implies. You do not need two installs. One install with two dependents on the same resolved version is enough, which is the ordinary shape of a monorepo: dozens of dependents collapsing onto one hoisted &lt;code&gt;name@version&lt;/code&gt;, one object in the cache. A team reading only the release note will conclude they are unaffected because they run a single &lt;code&gt;pnpm install&lt;/code&gt; in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two symptoms that read as flaky CI
&lt;/h2&gt;

&lt;p&gt;The loud symptom is a lockfile that stopped being a pure function of &lt;code&gt;package.json&lt;/code&gt;, the registry and the pnpmfile. Resolution order became an input. Two runners that walk the graph in a different order can write different &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; files, which shows up downstream as a &lt;code&gt;--frozen-lockfile&lt;/code&gt; failure nobody can reproduce locally, gets labelled flake, and gets a retry. It joins a small family of pnpm failures that look environmental and are not, alongside the &lt;a href="///blog/fix-err-pnpm-package-manager-remove-modules-dir-4-causes.html"&gt;four causes behind ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The quiet symptom is worse, and it is the one I would go looking for first. A conditional hook that inspects &lt;code&gt;pkg.dependencies&lt;/code&gt; before deciding whether to act can read a value some other dependent already mutated, take the wrong branch, and skip the package the rule exists for. Over-application is visible in a lockfile diff. Under-application produces a control that silently no-ops on the single dependency it was written to constrain, and the review that approved that control does not run again.&lt;/p&gt;

&lt;p&gt;There is a third artifact riding the same object. The changeset names it: a &lt;code&gt;deprecated&lt;/code&gt; notice read from the lockfile was written into the cached manifest and reused for the next dependent, producing a deprecation warning attributed to a package that is not deprecated in that position. Chasing that one through a registry and a lockfile burns an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is the copy deliberately shallow?
&lt;/h2&gt;

&lt;p&gt;The PR is explicit that the copy is "deliberately shallow and limited to the fields those writes reach": dependency and peer fields, with a follow-up commit extending it to copy each &lt;code&gt;peerDependenciesMeta&lt;/code&gt; entry. The justification is cost. The PR cites roughly 0.1µs for the shallow copy against roughly 37µs per manifest for a deep clone, which on a large tree is the difference between free and noticeable.&lt;/p&gt;

&lt;p&gt;I think that tradeoff is correct, and it also defines your remaining exposure precisely. A hook that writes to &lt;code&gt;pkg.scripts&lt;/code&gt;, &lt;code&gt;pkg.bin&lt;/code&gt;, &lt;code&gt;pkg.engines&lt;/code&gt;, or anything nested below a field outside the copied set is still mutating a shared object on 11.27.0. The version number is not the condition for safety. The absence of &lt;code&gt;pkg.*&lt;/code&gt; assignments in your pnpmfile is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The docs still teach the unsafe pattern
&lt;/h2&gt;

&lt;p&gt;The example on &lt;a href="https://pnpm.io/pnpmfile" rel="noopener noreferrer"&gt;pnpm.io/pnpmfile&lt;/a&gt; mutates in place (&lt;code&gt;pkg.dependencies.baz = '1.2.3'&lt;/code&gt;) and returns &lt;code&gt;pkg&lt;/code&gt;. The page warns that mutations "will affect what gets resolved in the lockfile" and that you may need to delete &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;. It says nothing about aliasing. Anything trained on that page, a coding model included, will hand you the same shape, which is how the pattern spread in the first place.&lt;/p&gt;

&lt;p&gt;The same page already documents a limit that a lot of teams have misread. Deleting &lt;code&gt;pkg.scripts&lt;/code&gt; in &lt;code&gt;readPackage&lt;/code&gt; does not stop pnpm building the package, because pnpm reads the real &lt;code&gt;package.json&lt;/code&gt; out of the archive at build time. Install-script suppression belongs in pnpm's trust and build-approval settings. If your threat model for postinstall execution is a &lt;code&gt;delete pkg.scripts&lt;/code&gt; line in a pnpmfile, that control has never fired, and the npm side of the same argument is worth reading in &lt;a href="///blog/npm-12-rollout-what-breaks-after-ci-goes-green.html"&gt;what breaks after npm 12 CI goes green&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same object problem, three surfaces
&lt;/h2&gt;

&lt;p&gt;Resolution is where this was found, not where it ends. Open PR &lt;a href="https://github.com/pnpm/pnpm/pull/14932" rel="noopener noreferrer"&gt;#14932&lt;/a&gt; reports &lt;code&gt;pnpm update&lt;/code&gt; and &lt;code&gt;pnpm audit --fix=update&lt;/code&gt; writing hook-injected dependencies into the on-disk &lt;code&gt;package.json&lt;/code&gt;, because the same edits were applied both to the effective manifest used for resolution and to the original manifest written back to disk. A hook meant to be a resolution-time overlay quietly becomes a commit.&lt;/p&gt;

&lt;p&gt;Open issue &lt;a href="https://github.com/pnpm/pnpm/issues/15025" rel="noopener noreferrer"&gt;#15025&lt;/a&gt; shows the same family of defect at the fetch layer: resolution keys a cache entry by tarball URL while install derives &lt;code&gt;name@version&lt;/code&gt;, so a custom fetcher runs twice for the same package. Three different surfaces, one root pattern of identity confusion between a cached artifact and a per-dependent view of it. That pattern tends to come in clusters, which is why the audit below is worth running even if your lockfile looks clean today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change in your pnpmfile this week
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade to 11.27.0 or later&lt;/strong&gt;, then stop mutating regardless. The hook contract is the return value, not the argument. Returning &lt;code&gt;pkg&lt;/code&gt; untouched is fine because no write happens. Everything else gets a spread:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hooks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;readPackage &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dependencies&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;baz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dependencies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;baz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.2.3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the writes you already have.&lt;/strong&gt; Name them rather than eyeballing the file:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'pkg\.[A-Za-z]+(\[[^]]*\])?\s*=|delete pkg\.'&lt;/span&gt; .pnpmfile.cjs .pnpmfile.mjs 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any hit outside dependency and peer fields is still unprotected on 11.27.0.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate an aliasing leak from an ordinary resolution change&lt;/strong&gt; with a two-run diff:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;pnpm-lock.yaml /tmp/lock.base
pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--lockfile-only&lt;/span&gt; &lt;span class="nt"&gt;--ignore-pnpmfile&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp &lt;/span&gt;pnpm-lock.yaml /tmp/lock.nohook
git checkout pnpm-lock.yaml
pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--lockfile-only&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; diff &lt;span class="nt"&gt;-u&lt;/span&gt; /tmp/lock.base pnpm-lock.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A diff that appears only with the hook enabled, on a package your condition does not match, is the leak. A diff that survives &lt;code&gt;--ignore-pnpmfile&lt;/code&gt; is a normal resolution change and needs no pnpmfile work. For the deprecation ghost, re-run against a throwaway store with &lt;code&gt;pnpm install --store-dir "$(mktemp -d)"&lt;/code&gt; and see whether the warning survives a cache holding no lockfile-sourced notice.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Gate it in CI instead of in review.&lt;/strong&gt; &lt;code&gt;pnpm install --frozen-lockfile&lt;/code&gt; followed by &lt;code&gt;git diff --exit-code pnpm-lock.yaml&lt;/code&gt; converts order-dependent resolution into a failing job with a name, rather than an intermittent install error three steps later. Check &lt;code&gt;git diff --exit-code package.json&lt;/code&gt; in the same step while &lt;a href="https://github.com/pnpm/pnpm/pull/14932" rel="noopener noreferrer"&gt;#14932&lt;/a&gt; is open.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Retire the extra job on a condition, not a date.&lt;/strong&gt; Drop it when the grep in step 2 returns nothing, meaning your pnpmfile performs no assignment to &lt;code&gt;pkg.*&lt;/code&gt; at all. As of 18 September 2026, upgrading alone does not get you there.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is returning &lt;code&gt;pkg&lt;/code&gt; unchanged from &lt;code&gt;readPackage&lt;/code&gt; safe?&lt;/strong&gt;&lt;br&gt;
Yes. The hazard is the write, not the return. A hook that inspects the manifest and returns the same object without assigning to it never touches the resolver's cached copy, on any version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does pnpm 11.27.0 cover edits to &lt;code&gt;pkg.scripts&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
No. PR #14014 states the copy is deliberately shallow and limited to the fields the known writes reach, meaning dependency and peer fields plus each &lt;code&gt;peerDependenciesMeta&lt;/code&gt; entry. Edits to &lt;code&gt;scripts&lt;/code&gt;, &lt;code&gt;bin&lt;/code&gt;, &lt;code&gt;engines&lt;/code&gt; or anything nested under an uncopied field still mutate a shared object.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my pnpm lockfile differ between CI runners?&lt;/strong&gt;&lt;br&gt;
If you run a pnpmfile that mutates manifests on pnpm older than 11.27.0, resolution order is an input to the result, so two runners can legitimately produce different lockfiles from identical inputs. Use the &lt;code&gt;--ignore-pnpmfile&lt;/code&gt; comparison above to confirm before blaming the runner image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can &lt;code&gt;readPackage&lt;/code&gt; block a package's install scripts?&lt;/strong&gt;&lt;br&gt;
No. pnpm reads the real &lt;code&gt;package.json&lt;/code&gt; from the package archive at build time, so deleting &lt;code&gt;pkg.scripts&lt;/code&gt; in the hook changes resolution metadata and nothing about what runs. Use pnpm's trust and build-approval settings for that, per &lt;a href="https://pnpm.io/pnpmfile" rel="noopener noreferrer"&gt;pnpm.io/pnpmfile&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/pnpm-readpackage-edits-leak-to-deps-you-never-matched.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Fix Terraform 1.16.3 binary/octet-stream: the zip is fine</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Fri, 18 Sep 2026 02:12:16 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/fix-terraform-1163-binaryoctet-stream-the-zip-is-fine-36oa</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/fix-terraform-1163-binaryoctet-stream-the-zip-is-fine-36oa</guid>
      <description>&lt;p&gt;Terraform 1.16.3 shipped on 16 September 2026, and within hours every CI job that fetches the CLI through HashiCorp's own Go installer started refusing it. This takes you from the error string to a running plan in about ten minutes, and leaves you with a one-line test that tells you when the workaround can come out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The archive is not corrupt. &lt;code&gt;releases.hashicorp.com&lt;/code&gt; serves &lt;code&gt;terraform_1.16.3_*.zip&lt;/code&gt; with &lt;code&gt;content-type: binary/octet-stream&lt;/code&gt;, and &lt;code&gt;hc-install&lt;/code&gt; only accepts &lt;code&gt;application/zip&lt;/code&gt; or &lt;code&gt;application/x-zip-compressed&lt;/code&gt;, so it aborts before it unpacks anything. &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;tfenv&lt;/code&gt; and &lt;code&gt;tfswitch&lt;/code&gt; never read that header, so the same URL works by hand. Pin &lt;code&gt;!= 1.16.3&lt;/code&gt; or hand your consumer a binary it already has, then watch the header for the server-side fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The failure looks like this, from Atlantis, from provider acceptance tests, from anything embedding &lt;code&gt;hashicorp/hc-install&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error downloading terraform version 1.16.3: unexpected content-type: binary/octet-stream (expected any of ["application/x-zip-compressed" "application/zip"])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different problems print almost that same sentence, and the open threads run them together: a release-side metadata regression from last week, a 2022-era check at a completely different stage of the install, and a corporate proxy rewriting the header in transit. &lt;a href="https://github.com/hashicorp/terraform/issues/39235" rel="noopener noreferrer"&gt;hashicorp/terraform#39235&lt;/a&gt; and &lt;a href="https://github.com/hashicorp/hc-install/issues/407" rel="noopener noreferrer"&gt;hashicorp/hc-install#407&lt;/a&gt; were both filed on 16 September 2026 and, at the time of writing, neither carries a maintainer fix. Any model answering from training data older than that will call it a corrupt download or a bad mirror and send you to clear caches. One warning summary with several unrelated triggers is the same diagnostic trap as &lt;a href="///blog/fix-terraform-the-deprecation-originates-from-warnings.html"&gt;Terraform 1.15's deprecation-mark warnings&lt;/a&gt;: you cannot pick a fix until you know which trigger you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; 7.x or later with outbound access to &lt;code&gt;releases.hashicorp.com&lt;/code&gt;, plus &lt;code&gt;sha256sum&lt;/code&gt; and &lt;code&gt;unzip&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One affected consumer to reproduce against: Atlantis v0.44.0 or similar, a provider repo whose acceptance tests use &lt;code&gt;terraform-plugin-testing&lt;/code&gt;, or a Go program importing &lt;code&gt;hc-install&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A known-good binary to fall back to. 1.16.2 is unaffected, and a patch-level rollback is cheap here, unlike the version-specific state behaviour behind &lt;a href="///blog/fix-terraform-deposed-object-panic-in-readdiff-on-1-15.html"&gt;the deposed-object panic in &lt;code&gt;readDiff&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Shell access on the host where the download actually runs. Reproducing on a laptop will not reproduce the proxy variant, which is the point of step 2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the three threads actually say: #39235 is the Terraform-side report with the confirmed-good checksum and the &lt;code&gt;!= 1.16.3&lt;/code&gt; workaround; &lt;a href="https://github.com/hashicorp/hc-install/issues/407" rel="noopener noreferrer"&gt;hc-install#407&lt;/a&gt; argues the library should accept the generic type because checksum and signature verification happen after the download anyway; &lt;a href="https://github.com/runatlantis/atlantis/issues/6906" rel="noopener noreferrer"&gt;runatlantis/atlantis#6906&lt;/a&gt; is the same break surfacing as failed plans on pull requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Read which stage of the install failed
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;hc-install&lt;/code&gt; validates content types in two separate places and the messages differ by one substring. A message naming &lt;code&gt;application/vnd+hashicorp.releases-api.v0+json&lt;/code&gt; comes from the releases-index stage in &lt;code&gt;releasesjson/releases.go&lt;/code&gt;, which is the 2022 problem from hc-install issues #56 and #59, relaxed by PR #57. A message naming &lt;code&gt;binary/octet-stream&lt;/code&gt; comes from the archive stage in &lt;code&gt;internal/releasesjson/downloader.go&lt;/code&gt;, inside &lt;code&gt;DownloadAndUnpack()&lt;/code&gt;, which compares the response header against a two-entry list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;zipMimeTypes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"application/x-zip-compressed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// Windows&lt;/span&gt;
    &lt;span class="s"&gt;"application/zip"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c"&gt;// Unix&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the second check is live today. Sorting this out first keeps you from applying a four-year-old library upgrade to a two-day-old server-side break.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Ask the origin what it is serving, and compare two versions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSIL&lt;/span&gt; https://releases.hashicorp.com/terraform/1.16.3/terraform_1.16.3_linux_amd64.zip | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^content-type'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sSIL&lt;/span&gt; https://releases.hashicorp.com/terraform/1.16.2/terraform_1.16.2_linux_amd64.zip | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^content-type'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect &lt;code&gt;content-type: binary/octet-stream&lt;/code&gt; on the first line and &lt;code&gt;content-type: application/zip&lt;/code&gt; on the second. The 1.16.3 object did not get the per-object metadata that every previous release got. Run this from the failing host rather than your workstation: a header rewritten by an intercepting proxy or an Artifactory remote repository only shows up on the path CI takes. If 1.16.3 already returns &lt;code&gt;application/zip&lt;/code&gt; for you, HashiCorp has re-tagged the object and whatever you are still seeing is cached or proxy-side.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Prove the bytes are good before you touch anything else
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSLO&lt;/span&gt; https://releases.hashicorp.com/terraform/1.16.3/terraform_1.16.3_linux_amd64.zip
curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://releases.hashicorp.com/terraform/1.16.3/terraform_1.16.3_SHA256SUMS &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep &lt;/span&gt;linux_amd64 | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output: &lt;code&gt;terraform_1.16.3_linux_amd64.zip: OK&lt;/code&gt;. The reporter on #39235 did the same check against &lt;code&gt;terraform_1.16.3_darwin_arm64.zip&lt;/code&gt; and got a valid zip with a matching SHA256. Two things follow. You can stop clearing caches, and you now have the argument that makes a correct fix stick: &lt;code&gt;hc-install&lt;/code&gt; verifies checksum and signature &lt;em&gt;after&lt;/em&gt; the download, so the header it rejected was never the integrity control. Trusting an artifact by its checksum instead of by transport metadata is the same discipline that makes &lt;a href="///blog/pin-github-actions-by-sha-10-gaps-the-pin-leaves-open.html"&gt;SHA-pinned GitHub Actions&lt;/a&gt; worth the friction.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Apply the fix that matches your consumer
&lt;/h3&gt;

&lt;p&gt;Atlantis resolves a Terraform version from &lt;code&gt;PATH&lt;/code&gt; and its own binary directory before it reaches out to &lt;code&gt;releases.hashicorp.com&lt;/code&gt;, and per the &lt;a href="https://www.runatlantis.io/docs/server-configuration" rel="noopener noreferrer"&gt;server configuration reference&lt;/a&gt; &lt;code&gt;--tf-download=false&lt;/code&gt; stops the outbound attempt entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;atlantis server &lt;span class="nt"&gt;--tf-download&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="nt"&gt;--default-tf-version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;v1.16.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Provider acceptance tests take the binary from an environment variable instead. HashiCorp's &lt;a href="https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests" rel="noopener noreferrer"&gt;acceptance testing docs&lt;/a&gt; describe &lt;code&gt;TF_ACC_TERRAFORM_PATH&lt;/code&gt; as the path to an existing binary; leaving both it and &lt;code&gt;TF_ACC_TERRAFORM_VERSION&lt;/code&gt; unset is what makes the harness install the latest CLI into a temp directory on every run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_ACC_TERRAFORM_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; terraform&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;TF_ACC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 go &lt;span class="nb"&gt;test&lt;/span&gt; ./internal/provider/... &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap is documented in that same page and costs people an afternoon: if &lt;code&gt;TF_ACC_TERRAFORM_PATH&lt;/code&gt; points at something missing or non-executable &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;TF_ACC_TERRAFORM_VERSION&lt;/code&gt; is set, the harness installs anyway and the error comes straight back. Set the path, then confirm the binary is there.&lt;/p&gt;

&lt;p&gt;For a Go program of your own, change the install source so it locates a binary rather than fetching one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;execPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;install&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewInstaller&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ensure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AnyVersion&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Terraform&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c"&gt;// was: &amp;amp;releases.LatestVersion{Product: product.Terraform}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Ensure()&lt;/code&gt; walks the slice and falls through to the next source, so leaving the &lt;code&gt;releases&lt;/code&gt; entry in the list puts the download straight back on the critical path.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Pin around the release where a constraint drives the download
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.14.0, != 1.16.3"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the workaround posted on #39235 and it resolves to 1.16.2. Be clear with your team about what it does not cover: it constrains version &lt;em&gt;selection&lt;/em&gt;, so it helps tooling that reads the constraint and does nothing for a pipeline with &lt;code&gt;1.16.3&lt;/code&gt; hardcoded in a Dockerfile, a &lt;code&gt;setup-terraform&lt;/code&gt; step, or a build matrix.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Keep the decision tree somewhere the on-call can find it
&lt;/h3&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["error downloading terraform version 1.16.3"] --&amp;gt; B{"Which content-type\nis named?"}
  B --&amp;gt;|releases-api v0 json| C["Index stage\nUpgrade hc-install past PR #57"]
  B --&amp;gt;|binary/octet-stream| D{"curl -sSIL the same URL\nfrom the failing host"}
  D --&amp;gt;|binary/octet-stream| E["Release-side metadata\nPre-install or pin != 1.16.3"]
  D --&amp;gt;|application/zip| F["A proxy rewrote the header\nFix the artifact proxy"]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Verify it works
&lt;/h2&gt;

&lt;p&gt;Re-run whatever failed, and check the binary rather than the exit code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform version
&lt;span class="c"&gt;# Terraform v1.16.2&lt;/span&gt;
&lt;span class="c"&gt;# on linux_amd64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Atlantis, a re-planned pull request is the proof. For acceptance tests, &lt;code&gt;TF_LOG=info go test ...&lt;/code&gt; shows which path the harness chose, which is how you catch the &lt;code&gt;TF_ACC_TERRAFORM_PATH&lt;/code&gt; trap from step 4.&lt;/p&gt;

&lt;p&gt;Then leave the durable check behind, because the real fix lands server-side and nobody will announce it to you. The header test from step 2 is your tripwire:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSIL&lt;/span&gt; https://releases.hashicorp.com/terraform/1.16.3/terraform_1.16.3_linux_amd64.zip &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/zip'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"re-tagged, drop the pin"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that in the same cron or CI job that already checks for stale pins, with a link to #39235 in the comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clearing caches and re-pulling.&lt;/strong&gt; Step 3 already proved the bytes are correct. Nothing local is wrong, and every minute spent on Docker layers or &lt;code&gt;~/.terraform.d&lt;/code&gt; is wasted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blaming tfenv, mise, or the laptop.&lt;/strong&gt; Those downloaders do not inspect the content type, so a clean local install is perfectly consistent with a hard CI outage. It is not a signal either way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forking &lt;code&gt;hc-install&lt;/code&gt; to skip the MIME check and disabling &lt;code&gt;VerifyChecksum&lt;/code&gt; in the same commit.&lt;/strong&gt; Relaxing the allowlist is defensible precisely because signature and checksum verification run after the download. Turning off the checksum removes the control that made the argument work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting a provider mirror to cover it.&lt;/strong&gt; &lt;code&gt;filesystem_mirror&lt;/code&gt; and &lt;code&gt;network_mirror&lt;/code&gt; apply to providers. The CLI binary is fetched by a different mechanism and mirror configuration has no effect on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A warm GitHub Actions tool cache hiding the break&lt;/strong&gt; until a cold runner picks up a job, which turns a deterministic failure into an intermittent one. That class of late-surfacing breakage is worth reading up on separately in &lt;a href="///blog/npm-12-rollout-what-breaks-after-ci-goes-green.html"&gt;what breaks after CI goes green&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-extending the downgrade reasoning.&lt;/strong&gt; 1.16.3 to 1.16.2 is patch-level and state stays readable. Do not reuse that logic for a minor downgrade after a state format upgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is the Terraform 1.16.3 download corrupt?&lt;/strong&gt;&lt;br&gt;
No. The reporter on hashicorp/terraform#39235 verified the downloaded zip against the published &lt;code&gt;SHA256SUMS&lt;/code&gt; and it matched. The rejection happens on the HTTP response header, before any unpacking or verification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does &lt;code&gt;curl&lt;/code&gt; or tfenv work while Atlantis fails on the same URL?&lt;/strong&gt;&lt;br&gt;
Only &lt;code&gt;hc-install&lt;/code&gt; compares the response &lt;code&gt;content-type&lt;/code&gt; against an allowlist. Generic downloaders write the bytes to disk regardless of what the header says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will upgrading hc-install fix it?&lt;/strong&gt;&lt;br&gt;
Only if your error names &lt;code&gt;application/vnd+hashicorp.releases-api.v0+json&lt;/code&gt;, which is the index-stage check relaxed back in 2022 by PR #57. The &lt;code&gt;binary/octet-stream&lt;/code&gt; message comes from a separate check in &lt;code&gt;DownloadAndUnpack()&lt;/code&gt; that, as of hc-install#407, has not been changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know when to remove the pin?&lt;/strong&gt;&lt;br&gt;
Run the &lt;code&gt;curl -sSIL&lt;/code&gt; tripwire from the Verify section. When 1.16.3 answers with &lt;code&gt;application/zip&lt;/code&gt;, the object has been re-tagged and the constraint can go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;You end with a plan that runs, a one-line test that separates the release-side regression from a proxy rewriting headers, and something that tells you when the workaround expires. If you are doing this across more than a handful of pipelines, the lasting fix is to move CLI acquisition behind an internal artifact store with pinned checksums, so a vendor's object metadata never sits on the critical path to a plan again.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/fix-terraform-1-16-3-binary-octet-stream-the-zip-is-fine.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
    </item>
    <item>
      <title>Fix ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR: 4 causes</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Fri, 18 Sep 2026 02:07:04 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/fix-errpnpmpackagemanagerremovemodulesdir-4-causes-2gn6</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/fix-errpnpmpackagemanagerremovemodulesdir-4-causes-2gn6</guid>
      <description>&lt;p&gt;On Windows, pnpm 12.3.4 through 12.4.1 kills &lt;code&gt;pnpm install&lt;/code&gt; with &lt;code&gt;ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR  Failed to remove modules directory contents: Access is denied. (os error 5)&lt;/code&gt; while clearing &lt;code&gt;node_modules&lt;/code&gt;. By the end of this you will know which of four causes produced that string on your machine, the upgrade that fixes the common one, and the unlink sequence for a host you cannot upgrade today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;p&gt;The short version: Windows reports a junction as a symlink rather than a directory, so pnpm's Rust purge path took the file branch and called &lt;code&gt;DeleteFileW&lt;/code&gt;, which refuses directory links and returns &lt;code&gt;ERROR_ACCESS_DENIED&lt;/code&gt;. Upgrade to pnpm 12.4.2, released 15 September 2026. Everything after step 4 is for readers stuck on an older binary, or readers who upgraded and still see the string, because three other conditions print it.&lt;/p&gt;

&lt;p&gt;Two details make this worth writing down rather than guessing at. The break and the fix both landed inside a ten-day window in September 2026, and the reason a junction escapes the directory branch is visible only in the merged patch, not in any published doc. If you have been working through the &lt;a href="///blog/pnpm-12-upgrade-fix-12-rust-cli-traps-before-ci-breaks.html"&gt;pnpm 12 upgrade traps&lt;/a&gt;, treat this as one more Rust-CLI regression in that family. The shape also rhymes with &lt;a href="///blog/fix-uv-cannot-install-into-symlinked-directory-in-0-12-14.html"&gt;uv refusing to install into a symlinked directory&lt;/a&gt;: a package manager rewritten in Rust, a link type the old implementation tolerated, a new one that does not.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["pnpm install decides to purge node_modules"] --&amp;gt; B{"Which string printed?"}
  B --&amp;gt;|"ABORTED_REMOVE_MODULES_DIR_NO_TTY"| C["No terminal to confirm the purge\nSet CI=true or confirmModulesPurge=false"]
  B --&amp;gt;|"REMOVE_MODULES_DIR, os error 5"| D{"Windows host?"}
  D --&amp;gt;|"No"| E["Real permission denial\nRead the path named in the error"]
  D --&amp;gt;|"Yes"| F{"pnpm 12.3.4 to 12.4.1?"}
  F --&amp;gt;|"Yes"| G["Junction routed to DeleteFileW\nUpgrade to 12.4.2"]
  F --&amp;gt;|"No"| H["Entry held open by AV, indexer or editor\n12.4.2 retries, then reports the path"]
  A --&amp;gt; I{"Why a purge at all?"}
  I --&amp;gt;|"nodeLinker or virtualStoreDir moved"| J["Diff node_modules/.modules.yaml"]
  I --&amp;gt;|"node_modules is itself a symlink"| K["Issue 9973, still open"]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Windows 10 or 11, or a &lt;code&gt;windows-latest&lt;/code&gt; GitHub Actions runner. Linux and macOS never hit the junction path, since &lt;code&gt;remove_file&lt;/code&gt; unlinks a symlink there without complaint.&lt;/li&gt;
&lt;li&gt;pnpm 12.x, checked with &lt;code&gt;pnpm --version&lt;/code&gt;. Affected releases run from v12.3.4 (4 September 2026) to v12.4.1 (10 September 2026). The reporter in &lt;a href="https://github.com/pnpm/pnpm/issues/14790" rel="noopener noreferrer"&gt;pnpm issue #14790&lt;/a&gt; confirmed 12.3.4 and 12.4.0.&lt;/li&gt;
&lt;li&gt;Node.js 20 or newer, and a shell from which you can run PowerShell and &lt;code&gt;cmd.exe&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write access to &lt;code&gt;package.json&lt;/code&gt; for the &lt;code&gt;packageManager&lt;/code&gt; pin, and to your continuous integration (CI) workflow file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One assumption I am making: the purge itself is legitimate. pnpm clears &lt;code&gt;node_modules&lt;/code&gt; when the settings that shaped the existing tree no longer match the settings for the install about to run, and step 2 is how you confirm that rather than assume it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Read the string before you touch anything
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;install &lt;/span&gt;2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two error codes carry the words "modules dir" and they want opposite fixes. &lt;code&gt;ERR_PNPM_PACKAGE_MANAGER_REMOVE_MODULES_DIR&lt;/code&gt; means pnpm tried to purge and the filesystem refused. &lt;code&gt;ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY&lt;/code&gt; means pnpm never tried, because there was no terminal attached to confirm on, which is the case described in &lt;a href="https://github.com/pnpm/pnpm/issues/11562" rel="noopener noreferrer"&gt;issue #11562&lt;/a&gt;. Step 6 covers that second one; the rest of this page is about the first.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Find out why pnpm wants to purge
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;node_modules/.modules.yaml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"nodeLinker|virtualStoreDir|hoistPattern|publicHoistPattern"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pnpm writes the settings your current tree was built with into &lt;code&gt;node_modules/.modules.yaml&lt;/code&gt; and compares them against the settings for this run. A changed &lt;code&gt;nodeLinker&lt;/code&gt; is the usual trigger, and it is the exact reproduction in issue #14790: add a &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; containing &lt;code&gt;nodeLinker: hoisted&lt;/code&gt; after a default install. The default is &lt;code&gt;isolated&lt;/code&gt;, per the &lt;a href="https://pnpm.io/settings/node-modules" rel="noopener noreferrer"&gt;node-modules settings reference&lt;/a&gt;. A drifting &lt;code&gt;virtualStoreDir&lt;/code&gt; is the other one. &lt;a href="https://github.com/pnpm/pnpm/issues/12307" rel="noopener noreferrer"&gt;Issue #12307&lt;/a&gt; shows &lt;code&gt;.modules.yaml&lt;/code&gt; written twice with conflicting values when &lt;code&gt;enableGlobalVirtualStore: true&lt;/code&gt; and the install runs from inside a workspace package, which re-purges on every install even though nothing changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. List the directory links pnpm is choking on
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;node_modules&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LinkType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;LinkType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Target&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row with a &lt;code&gt;LinkType&lt;/code&gt; of &lt;code&gt;Junction&lt;/code&gt; or &lt;code&gt;SymbolicLink&lt;/code&gt; is a candidate. From &lt;code&gt;cmd.exe&lt;/code&gt;, &lt;code&gt;dir /A:L node_modules&lt;/code&gt; gives the same picture, and &lt;code&gt;fsutil reparsepoint query node_modules\is-odd&lt;/code&gt; confirms one specific entry is a reparse point. An isolated install creates one such link per direct dependency, so a plain &lt;code&gt;pnpm install&lt;/code&gt; on a project with a single dependency is enough to reproduce the failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Upgrade to 12.4.2, then pin it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm self-update
pnpm &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# expect 12.4.2 or newer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/pnpm/pnpm/pull/14798" rel="noopener noreferrer"&gt;PR #14798&lt;/a&gt;, merged 14 September 2026, replaces the &lt;code&gt;std::fs::remove_file&lt;/code&gt; call in &lt;code&gt;crates/package-manager/src/install/prepare_modules_state/purge.rs&lt;/code&gt; with &lt;code&gt;pnpm_fs::remove_dirent&lt;/code&gt;, which routes directory links through &lt;code&gt;remove_symlink_dir&lt;/code&gt; and retries transient locks. The &lt;a href="https://github.com/pnpm/pnpm/releases/tag/v12.4.2" rel="noopener noreferrer"&gt;v12.4.2 release notes&lt;/a&gt; state it plainly: &lt;code&gt;pnpm install&lt;/code&gt; on Windows no longer fails with this code when clearing a &lt;code&gt;node_modules&lt;/code&gt; that contains linked dependencies, "such as when changing &lt;code&gt;nodeLinker&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;Pin the version so a stale global binary on one laptop or one runner does not walk you back into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packageManager"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pnpm@12.4.2"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. If you cannot upgrade today, unlink before you install
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="na"&gt;/d &lt;/span&gt;&lt;span class="vm"&gt;%d&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;node_modules&lt;/span&gt;\&lt;span class="o"&gt;*)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="nb"&gt;rmdir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="vm"&gt;%d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="kr"&gt;nul&lt;/span&gt;
&lt;span class="nb"&gt;rmdir&lt;/span&gt; &lt;span class="na"&gt;/s /q &lt;/span&gt;&lt;span class="kd"&gt;node_modules&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rmdir&lt;/code&gt; against a junction removes the link and leaves the target alone, which is the call the old code path skipped. Run it from &lt;code&gt;cmd.exe&lt;/code&gt;, not from Windows PowerShell 5.1, for the reason in the pitfalls below. Then run &lt;code&gt;pnpm install&lt;/code&gt; normally.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Make CI answer the purge question instead of aborting
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm install --config.confirmModulesPurge=false&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;CI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;confirmModulesPurge&lt;/code&gt; appears in neither the settings reference nor the &lt;code&gt;pnpm install&lt;/code&gt; CLI page; the error text is its documentation, and &lt;a href="https://github.com/pnpm/pnpm/issues/6778" rel="noopener noreferrer"&gt;issue #6778&lt;/a&gt; is where the &lt;code&gt;--config.&lt;/code&gt; prefix form is confirmed. Setting &lt;code&gt;CI=true&lt;/code&gt; has the same effect. Apply this only to the NO_TTY variant, since it does nothing at all for os error 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it works
&lt;/h2&gt;

&lt;p&gt;Reproduce the original trigger deliberately in a throwaway directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;pnpm-purge-check &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;pnpm-purge-check
pnpm init
pnpm add is-odd@3.0.1
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'nodeLinker: hoisted\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; pnpm-workspace.yaml
pnpm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 12.3.4 through 12.4.1 that last &lt;code&gt;pnpm install&lt;/code&gt; exits non-zero with &lt;code&gt;Failed to remove modules directory contents: Access is denied. (os error 5)&lt;/code&gt;. On 12.4.2 it completes on the first attempt, and the word "first" is load-bearing: the broken versions often succeed on a second run, because the failed pass removed enough of the tree to change what the next pass encounters.&lt;/p&gt;

&lt;p&gt;Then confirm the tree really flipped linker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;node_modules\.modules.yaml&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodeLinker"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;node_modules&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LinkType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Measure-Object&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hoisted tree reports a &lt;code&gt;nodeLinker: hoisted&lt;/code&gt; line and a link count at or near zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Filing it as flaky CI.&lt;/strong&gt; The retry-succeeds pattern looks exactly like a race, and the usual response is a &lt;code&gt;retry: 2&lt;/code&gt; on the job. The failure is a deterministic type check, and the retry buys silence until the first pass fails somewhere less convenient, such as a release build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Remove-Item -Recurse&lt;/code&gt; in Windows PowerShell 5.1.&lt;/strong&gt; It has a long history of walking through a junction instead of deleting it, which turns a cleanup step into deleting the contents of your pnpm content-addressable store. Use &lt;code&gt;cmd /c rmdir&lt;/code&gt;, or PowerShell 7.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reaching for &lt;code&gt;confirmModulesPurge=false&lt;/code&gt; first.&lt;/strong&gt; It suppresses the confirmation prompt, and the removal still runs. On the Windows bug the purge proceeds and fails anyway; on a repo whose &lt;code&gt;node_modules&lt;/code&gt; should never have been purged, it deletes the tree without asking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assuming 12.4.2 ends every occurrence.&lt;/strong&gt; A genuinely locked entry produces the same code: an antivirus (AV) scan, the Windows Search indexer, a &lt;code&gt;node.exe&lt;/code&gt; still running, an editor holding a file open. On 12.4.2 the message is worth reading, because &lt;a href="https://github.com/pnpm/pnpm/pull/14605" rel="noopener noreferrer"&gt;PR #14605&lt;/a&gt;, merged 8 September 2026, changed the output from a bare OS error to &lt;code&gt;Failed to remove C:\...\node_modules\is-odd from the modules directory&lt;/code&gt;, naming the entry. Open the path, close whatever holds it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A symlinked &lt;code&gt;node_modules&lt;/code&gt; on a deploy host.&lt;/strong&gt; Capistrano-style layouts that point &lt;code&gt;node_modules&lt;/code&gt; at a shared cache take a full purge on every deploy. &lt;a href="https://github.com/pnpm/pnpm/issues/9973" rel="noopener noreferrer"&gt;Issue #9973&lt;/a&gt; has been open since September 2025 with no maintainer answer, so treat a symlinked &lt;code&gt;node_modules&lt;/code&gt; as unsupported until one arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;You have the version boundary (12.3.4 to 12.4.1 broken, 12.4.2 fixed), the mechanism (junction reported as a symlink, routed to &lt;code&gt;DeleteFileW&lt;/code&gt;, refused), a PowerShell one-liner that lists the offending links, and a separating test for each of the three causes an upgrade will not touch.&lt;/p&gt;

&lt;p&gt;The follow-up worth doing this week: pin &lt;code&gt;packageManager&lt;/code&gt; in every repo and put that pinned version in your Windows runner matrix, so the next Rust-CLI regression surfaces in one job instead of on one engineer's laptop at 5pm. The &lt;a href="///blog/fix-pnpm-deploy-cannot-find-module-in-docker-on-11-19.html"&gt;pnpm deploy symlink escape on 11.19&lt;/a&gt; is the same family of bug one layer up. For the npm-side version of one crash line covering several broken trees, see &lt;a href="///blog/fix-npm-cannot-read-properties-of-null-edgesout.html"&gt;the edgesOut crash&lt;/a&gt; and &lt;a href="///blog/fix-eslint-cannot-read-properties-of-undefined-intrinsic.html"&gt;the TypeScript 7 ESLint break&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/fix-err-pnpm-package-manager-remove-modules-dir-4-causes.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
    </item>
    <item>
      <title>Fix uv "Cannot install into symlinked directory" in 0.12.14</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Wed, 16 Sep 2026 02:08:14 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/fix-uv-cannot-install-into-symlinked-directory-in-01214-18bl</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/fix-uv-cannot-install-into-symlinked-directory-in-01214-18bl</guid>
      <description>&lt;p&gt;If a Docker build or CI job that passed yesterday now dies on &lt;code&gt;The wheel is invalid: Cannot install into symlinked directory&lt;/code&gt;, nothing is wrong with your wheel, image, or virtual environment. uv 0.12.14 (released 2026-09-15) started refusing to install a wheel whose destination tree contains a directory symlink, and uv 0.12.15 shipped the same day to revert it. Bump the version, then spend ten minutes finding the dependency that genuinely writes through a symlink, because the check it tripped is coming back.&lt;/p&gt;

&lt;p&gt;The change that breaks you is not in the 0.12.14 changelog. Those notes cover resumable downloads, &lt;code&gt;MAX_PATH&lt;/code&gt; support on Windows, and a new exit-code scheme; the word "symlink" does not appear. The behaviour arrived with &lt;a href="https://github.com/astral-sh/uv/pull/21569" rel="noopener noreferrer"&gt;pull request #21569, "Reject symlinked wheel installation destinations"&lt;/a&gt;, merged 2026-09-10 and unmentioned in the release. An assistant answering from the changelog will tell you your environment is broken, which is the same failure mode that makes version-gated breakage in other toolchains so expensive to diagnose, catalogued for the JavaScript side in &lt;a href="///blog/pnpm-12-upgrade-fix-12-rust-cli-traps-before-ci-breaks.html"&gt;the pnpm 12 upgrade traps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;uv 0.12.13, 0.12.14, or 0.12.15, and write access to wherever your pipeline decides the uv version. Check with &lt;code&gt;uv --version&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A Linux host or container to reproduce on. The Docker surface needs Docker plus a Debian-based official image (&lt;code&gt;python:3.11-slim-bookworm&lt;/code&gt;, &lt;code&gt;python:3.13-bookworm&lt;/code&gt;, &lt;code&gt;python:3.12-slim-trixie&lt;/code&gt; and siblings all carry the trigger). &lt;code&gt;-alpine&lt;/code&gt; images do not.&lt;/li&gt;
&lt;li&gt;Knowledge of where the version is actually pinned: a &lt;code&gt;COPY --from=ghcr.io/astral-sh/uv:&amp;lt;tag&amp;gt;&lt;/code&gt; line, an &lt;a href="https://github.com/astral-sh/setup-uv" rel="noopener noreferrer"&gt;&lt;code&gt;astral-sh/setup-uv&lt;/code&gt;&lt;/a&gt; step, a Renovate-managed tool version, or a base image with uv baked in. An unpinned tool fetched at build time is the same exposure class covered in &lt;a href="///blog/pin-github-actions-by-sha-10-gaps-the-pin-leaves-open.html"&gt;the gaps SHA-pinning GitHub Actions leaves open&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I read while writing this: the &lt;a href="https://github.com/astral-sh/uv/releases/tag/0.12.15" rel="noopener noreferrer"&gt;0.12.15 release notes&lt;/a&gt;, the &lt;a href="https://github.com/astral-sh/uv/releases/tag/0.12.14" rel="noopener noreferrer"&gt;0.12.14 release notes&lt;/a&gt;, the Docker reproduction in &lt;a href="https://github.com/astral-sh/uv/issues/21692" rel="noopener noreferrer"&gt;astral-sh/uv#21692&lt;/a&gt;, the &lt;code&gt;--target .&lt;/code&gt; report in &lt;a href="https://github.com/astral-sh/uv/issues/21694" rel="noopener noreferrer"&gt;astral-sh/uv#21694&lt;/a&gt;, and the reverted PR itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Read the path after the colon, not the wheel name
&lt;/h3&gt;

&lt;p&gt;The wheel named in the first line is a red herring. It is whichever wheel in the install set happened to carry a &lt;code&gt;.data/&lt;/code&gt; payload first. The diagnosis is the path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: Failed to install: clevercsv-0.8.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl (clevercsv==0.8.4)
  cause: The wheel is invalid: Cannot install into symlinked directory: /usr/local/man
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  cause: The wheel is invalid: Cannot install into symlinked directory: .../packages/robocar/simulation/.venv/lib64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is a system install into a Debian &lt;code&gt;python:*&lt;/code&gt; image, where &lt;code&gt;/usr/local/man&lt;/code&gt; is a symlink to &lt;code&gt;share/man&lt;/code&gt;. The second was reported on a GitHub Actions runner during &lt;code&gt;uv sync --extra dev&lt;/code&gt;, where &lt;code&gt;uv venv&lt;/code&gt; had created &lt;code&gt;lib64&lt;/code&gt; as a symlink to &lt;code&gt;lib&lt;/code&gt; on 64-bit Linux. A third surface prints a completely different string, which is why people miss it.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["error: The wheel is invalid"] --&amp;gt; B{"Which cause string?"}
  B --&amp;gt;|Cannot install into symlinked directory| C{"Path after the colon"}
  B --&amp;gt;|Wheel directory entry escapes its destination| D["--target=. or --target=./\nrelative root normalizes to an empty path"]
  C --&amp;gt;|/usr/local/man| E["uv pip install --system\ninto a Debian python:* image"]
  C --&amp;gt;|.venv/lib64| F["install into a venv on 64-bit Linux\nlib64 -&amp;gt; lib"]
  D --&amp;gt; G["Upgrade to uv 0.12.15"]
  E --&amp;gt; G
  F --&amp;gt; G
  G --&amp;gt; H["Then audit the wheel that\nactually crosses the symlink"]&lt;/code&gt;&lt;/pre&gt;



&lt;h3&gt;
  
  
  2. Confirm the symlink exists so you stop suspecting the wheel
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /usr/local &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 1 &lt;span class="nt"&gt;-type&lt;/span&gt; l &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%p -&amp;gt; %l\n'&lt;/span&gt;
find .venv &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 1 &lt;span class="nt"&gt;-type&lt;/span&gt; l &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%p -&amp;gt; %l\n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;python:3.11-slim-bookworm&lt;/code&gt; the first prints &lt;code&gt;/usr/local/man -&amp;gt; share/man&lt;/code&gt;. In a uv-created environment on x86-64 the second prints &lt;code&gt;.venv/lib64 -&amp;gt; lib&lt;/code&gt;. Both layouts are normal, both predate uv by years, and neither is something you introduced. Confirming this first stops the two hours people otherwise spend rebuilding wheels.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Reproduce it in three lines before changing anything
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.11-slim-bookworm&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ghcr.io/astral-sh/uv:0.12.14 /uv /bin/uv&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nv"&gt;clevercsv&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;0.8.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker build&lt;/code&gt; on that fails with the &lt;code&gt;/usr/local/man&lt;/code&gt; cause. It is the reproduction filed as &lt;a href="https://github.com/astral-sh/uv/issues/21692" rel="noopener noreferrer"&gt;astral-sh/uv#21692&lt;/a&gt; on 2026-09-15. &lt;code&gt;clevercsv&lt;/code&gt; is incidental: it ships man pages in its &lt;code&gt;.data/data/&lt;/code&gt; tree, so substitute any wheel that does. Having the failure on demand is what lets you prove the bump worked rather than hoping.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Pin 0.12.15 where the version actually comes from
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ghcr.io/astral-sh/uv:0.12.15 /uv /uvx /bin/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4&lt;/span&gt; &lt;span class="c1"&gt;# v10.1.0&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.12.15"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 0.12.15 notes are explicit: "This release fixes a regression in 0.12.14 that lead to rejecting valid installation commands such as using &lt;code&gt;uv pip install --system&lt;/code&gt; in &lt;code&gt;python:*&lt;/code&gt; docker images or when using &lt;code&gt;uv pip install --target .&lt;/code&gt;", with the single bug-fix entry "Revert 'Reject symlinked wheel installation destinations'".&lt;/p&gt;

&lt;p&gt;For a standalone-installer uv, &lt;code&gt;uv self update&lt;/code&gt; is enough. One trap on the Actions side: &lt;code&gt;setup-uv&lt;/code&gt;'s &lt;code&gt;version&lt;/code&gt; input searches your config files before falling back to the latest release, so if a committed uv version constraint is what resolved 0.12.14, editing the workflow alone will not move it. Change the constraint, not the step.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. If you cannot bump today, patch the destination
&lt;/h3&gt;

&lt;p&gt;Only worth doing for a hard-pinned 0.12.14 you cannot reach. For the Docker surface, replace the symlink with a real directory before installing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/local/man &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /usr/local/man
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the virtual-environment surface, pin the offending package away or commit the &lt;code&gt;uv.lock&lt;/code&gt; that never selected it. Rewriting &lt;code&gt;.venv/lib64&lt;/code&gt; is riskier, because the interpreter's own layout expects that symlink to be there.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Find the wheel that really crosses the symlink
&lt;/h3&gt;

&lt;p&gt;This is the step that survives the revert. Pull the wheel and list its data tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip download &lt;span class="nt"&gt;--no-deps&lt;/span&gt; &lt;span class="nt"&gt;--dest&lt;/span&gt; /tmp/wheels &lt;span class="nv"&gt;clevercsv&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;0.8.4
unzip &lt;span class="nt"&gt;-l&lt;/span&gt; /tmp/wheels/clevercsv-&lt;span class="k"&gt;*&lt;/span&gt;.whl | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'\.data/'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entries under &lt;code&gt;.data/data/&lt;/code&gt;, &lt;code&gt;.data/scripts/&lt;/code&gt;, or &lt;code&gt;.data/platlib/&lt;/code&gt; are the ones mapped onto scheme roots at install time, so they are the ones that can land inside a symlinked directory. Write down which of your dependencies have them. That list is your blast radius when the hardened check returns, and it is worth keeping next to whatever inventory you already maintain of packages that execute code at install time, the hazard that &lt;a href="///blog/arrayref-attack-shows-cargo-build-rs-runs-any-code.html"&gt;the arrayref attack showed for cargo build.rs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it works
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv &lt;span class="nt"&gt;--version&lt;/span&gt;
uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="nv"&gt;clevercsv&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;0.8.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect &lt;code&gt;uv 0.12.15&lt;/code&gt; and a successful &lt;code&gt;Installed 1 package&lt;/code&gt; line. Rebuild the step 3 Dockerfile with the &lt;code&gt;0.12.15&lt;/code&gt; tag and it completes. Then check the &lt;code&gt;--target&lt;/code&gt; surface separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; python3.14 &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--no-compile&lt;/span&gt; &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="nt"&gt;--target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; annotated-types&lt;span class="o"&gt;==&lt;/span&gt;0.8.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 0.12.14 that failed for every wheel with &lt;code&gt;The wheel is invalid: Wheel directory entry escapes its destination: annotated_types-0.8.0.dist-info&lt;/code&gt;, because the relative installation root &lt;code&gt;.&lt;/code&gt; normalizes to an empty path (&lt;a href="https://github.com/astral-sh/uv/issues/21694" rel="noopener noreferrer"&gt;astral-sh/uv#21694&lt;/a&gt;). Same pull request, different message, and no search engine currently connects the two strings. On 0.12.15 it installs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Deleting and recreating the virtual environment does nothing.&lt;/strong&gt; &lt;code&gt;uv venv&lt;/code&gt; recreates &lt;code&gt;lib64 -&amp;gt; lib&lt;/code&gt;, because that is the normal layout on 64-bit Linux rather than corruption. Advice that opens with "remove &lt;code&gt;.venv&lt;/code&gt;" is aimed at the one thing you cannot remove.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your exit codes changed in the same release.&lt;/strong&gt; The 0.12.14 notes state that "Package-operation exit codes now reflect the underlying cause: expected failures return 1, while recognized operational and internal failures return 2." A wrapper script branching on &lt;code&gt;$? -eq 1&lt;/code&gt; to mean "resolution failed" will now take the wrong branch on an operational failure, silently. Grep your CI for literal exit-code comparisons around uv while you are already in there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cached Docker layer hides the fix.&lt;/strong&gt; If the &lt;code&gt;COPY --from=ghcr.io/astral-sh/uv:0.12.14&lt;/code&gt; line sits above your dependency install and you only edited the tag, rebuild that stage with &lt;code&gt;--no-cache&lt;/code&gt;. Layer ordering makes image debugging misleading in general, which I went into alongside the containerd store in &lt;a href="///blog/fix-docker-save-no-suitable-export-target-found.html"&gt;docker save "no suitable export target found"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alpine passing convinces people the bug is not real.&lt;/strong&gt; &lt;code&gt;python:*-alpine&lt;/code&gt; has no &lt;code&gt;/usr/local/man&lt;/code&gt; symlink, so a matrix that only exercises Alpine stays green while every Debian job fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat the rejection as returning.&lt;/strong&gt; PR #21569 was reverted for the relative-path and system-directory regressions, not because its reasoning was wrong: when an installation destination already contains a directory symlink, merging the wheel payload can write files outside the environment. Pinning 0.12.15 buys time, which is why step 6 is the actual work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;You have the mapping from error path to surface, a three-line reproduction, a one-line bump to 0.12.15, per-surface workarounds for a pin you cannot move, and a list of dependencies whose &lt;code&gt;.data/&lt;/code&gt; trees make them candidates when the check comes back hardened.&lt;/p&gt;

&lt;p&gt;The next thing worth doing is to stop discovering uv releases through red CI. Add a scheduled matrix row that runs your install against the newest uv with &lt;code&gt;--no-cache&lt;/code&gt; on a Debian-based image. The window between the 0.12.14 regression and the 0.12.15 fix was hours, and the only teams who never noticed were the ones whose pinned version does not move on its own: &lt;a href="https://github.com/astral-sh/uv/releases/tag/0.12.15" rel="noopener noreferrer"&gt;https://github.com/astral-sh/uv/releases/tag/0.12.15&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/fix-uv-cannot-install-into-symlinked-directory-in-0-12-14.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
    </item>
    <item>
      <title>Fix "NVIDIA-SMI has failed" in a Kubernetes DRA Pod</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Tue, 15 Sep 2026 04:58:09 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/fix-nvidia-smi-has-failed-in-a-kubernetes-dra-pod-431a</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/fix-nvidia-smi-has-failed-in-a-kubernetes-dra-pod-431a</guid>
      <description>&lt;p&gt;You end this with a GPU visible inside a container scheduled through Dynamic Resource Allocation (DRA), a one-line check that tells this failure apart from every "your driver is broken" answer on the internet, and a systemd unit that stops the node re-arming the bug at the next reboot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; if &lt;code&gt;nvidia-smi&lt;/code&gt; works on the host but the pod prints &lt;code&gt;NVIDIA-SMI has failed&lt;/code&gt;, grep the claim's Container Device Interface (CDI) spec under &lt;code&gt;/var/run/cdi&lt;/code&gt; for &lt;code&gt;/dev/nvidia&lt;/code&gt;. A count of zero means the kubelet plugin generated and cached a spec with the NVIDIA libraries and no device nodes. Run &lt;code&gt;nvidia-modprobe -c 0 -u&lt;/code&gt; on the host, then delete the pod and claim and restart the plugin DaemonSet so the spec is regenerated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nvidia-smi works on the host and dies in the pod
&lt;/h2&gt;

&lt;p&gt;The failure looks like this inside the container, quoted from the report in &lt;a href="https://github.com/kubernetes-sigs/dra-driver-nvidia-gpu/issues/1380" rel="noopener noreferrer"&gt;kubernetes-sigs/dra-driver-nvidia-gpu issue #1380&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver.
Make sure that the latest NVIDIA driver is installed and running.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything around it reports success. The &lt;code&gt;ResourceClaim&lt;/code&gt; says &lt;code&gt;allocated,reserved&lt;/code&gt;, the pod reaches &lt;code&gt;Running&lt;/code&gt;, the kernel module is loaded, and &lt;code&gt;nvidia-smi&lt;/code&gt; on the node lists the cards. Nothing crashed, and the GPU is not in the container.&lt;/p&gt;

&lt;p&gt;That string has a well-known set of causes on a normal host: a failed DKMS rebuild, Secure Boot blocking the module, a userspace/kernel version mismatch, a card that fell off the PCIe bus. None of them apply here, because the host driver is fine. What broke is the CDI spec the kubelet plugin wrote for this specific claim: it carries the NVIDIA userspace libraries and no &lt;code&gt;/dev/nvidia*&lt;/code&gt; device nodes, so the NVIDIA Management Library (NVML) inside the container finds the tooling on &lt;code&gt;$PATH&lt;/code&gt; and no hardware to talk to. Same error text, different layer, and the split is the same shape as &lt;a href="///blog/fix-xid-154-gpu-recovery-action-changed-on-gpu-nodes.html"&gt;Xid 154 routing four different remediations through one log line&lt;/a&gt;: the symptom is a pointer, not a diagnosis. If the node also does GPU passthrough, settle &lt;a href="///blog/stop-kubernetes-dra-giving-one-gpu-to-a-vm-and-a-pod.html"&gt;the VFIO and DRA overlap&lt;/a&gt; before you start, because a card bound to &lt;code&gt;vfio-pci&lt;/code&gt; fails earlier and differently.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["Node boots, nvidia kernel module loads"] --&amp;gt; B["Pod scheduled, kubelet plugin runs NodePrepareResources"]
  B --&amp;gt; C["Plugin generates a CDI spec for the claim"]
  C --&amp;gt; D{"Do /dev/nvidia* exist yet?"}
  D --&amp;gt;|yes| E["Spec lists deviceNodes, container sees the GPU"]
  D --&amp;gt;|no| F["Spec has library mounts only, zero deviceNodes"]
  F --&amp;gt; G["Spec cached under /var/run/cdi plus checkpoint.json"]
  G --&amp;gt; H["Pod reaches Running, nvidia-smi exits 9"]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  What you need before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes 1.34 or newer, where core DRA is generally available and &lt;code&gt;resource.k8s.io/v1&lt;/code&gt; is on by default (&lt;a href="https://kubernetes.io/blog/2025/09/01/kubernetes-v1-34-dra-updates/" rel="noopener noreferrer"&gt;Kubernetes v1.34: DRA graduates to GA&lt;/a&gt;). Issue #1380 was reproduced on v1.36.2+k3s1. If you are still landing on the 1.34 line, the &lt;a href="///blog/fix-unrecognized-format-int32-in-kubernetes-1-34.html"&gt;&lt;code&gt;unrecognized format int32&lt;/code&gt; validation change&lt;/a&gt; came in the same wave.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dra-driver-nvidia-gpu&lt;/code&gt; v0.5.0, installed per the &lt;a href="https://dra-driver-nvidia-gpu.sigs.k8s.io/docs/install/" rel="noopener noreferrer"&gt;project install docs&lt;/a&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  helm &lt;span class="nb"&gt;install &lt;/span&gt;dra-driver-nvidia-gpu &lt;span class="se"&gt;\&lt;/span&gt;
    oci://registry.k8s.io/dra-driver-nvidia/charts/dra-driver-nvidia-gpu &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--version&lt;/span&gt; 0.5.0 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--namespace&lt;/span&gt; dra-driver-nvidia-gpu &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;gpuResourcesEnabledOverride&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;A root shell on the GPU node. Several steps read &lt;code&gt;/var/run/cdi&lt;/code&gt; and &lt;code&gt;/var/lib/kubelet&lt;/code&gt;, which the API server cannot show you.&lt;/li&gt;
&lt;li&gt;An NVIDIA kernel driver on the host. The issue used 580.105.08 on RTX PRO 2000 and RTX PRO 4000 Blackwell cards; nothing about the mechanism is card-specific.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl&lt;/code&gt; able to read &lt;code&gt;resourceclaims&lt;/code&gt; and delete pods in the workload namespace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One version check first. As of 15 September 2026, issue #1380 is open against v0.5.0 and carries the v0.5.1 milestone, which has not shipped. Look at &lt;a href="https://github.com/kubernetes-sigs/dra-driver-nvidia-gpu/releases" rel="noopener noreferrer"&gt;the releases page&lt;/a&gt; before you do any of this: if v0.5.1 is out by the time you read it, upgrade first and use the recovery below only for nodes already stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the claim's CDI spec, not the pod logs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Confirm the claim really was allocated.&lt;/strong&gt; Rule out the boring case, a pod that never got a claim.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get resourceclaims &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
kubectl get resourceclaim &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAIM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.allocation.devices.results}'&lt;/span&gt; | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty &lt;code&gt;status&lt;/code&gt; or a &lt;code&gt;Pending&lt;/code&gt; pod is a scheduling problem and stops here. &lt;code&gt;allocated,reserved&lt;/code&gt; plus a &lt;code&gt;Running&lt;/code&gt; pod is the signature you are chasing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Ask the host whether the device files exist.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/nvidia&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a fresh headless node that has never run an NVIDIA userspace client, this returns &lt;code&gt;No such file or directory&lt;/code&gt;, which is the core observation in issue #1380. The module is loaded and the character devices were never created. &lt;code&gt;nvidia-modprobe&lt;/code&gt; exists for exactly this reason: creating the device files is normally a distro or udev job and is not guaranteed to have happened, which is why the tool is installed setuid root to do it on demand (&lt;a href="https://manpages.ubuntu.com/manpages/jammy/man1/nvidia-modprobe.1.html" rel="noopener noreferrer"&gt;nvidia-modprobe(1)&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Count the device nodes in the spec.&lt;/strong&gt; This is the command that splits the two failure modes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CLAIM_UID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get resourceclaim &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAIM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.metadata.uid}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'/dev/nvidia'&lt;/span&gt; &lt;span class="s2"&gt;"/var/run/cdi/k8s.gpu.nvidia.com-claim_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAIM_UID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.yaml"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Name the variable something other than &lt;code&gt;UID&lt;/code&gt;: bash marks &lt;code&gt;UID&lt;/code&gt; readonly, and the assignment fails in an interactive root shell before you ever reach the grep. A healthy spec lists &lt;code&gt;/dev/nvidia0&lt;/code&gt;, &lt;code&gt;/dev/nvidiactl&lt;/code&gt;, &lt;code&gt;/dev/nvidia-uvm&lt;/code&gt; and &lt;code&gt;/dev/nvidia-uvm-tools&lt;/code&gt; under &lt;code&gt;containerEdits.deviceNodes&lt;/code&gt;, the structure NVIDIA documents for CDI-generated specs (&lt;a href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/cdi-support.html" rel="noopener noreferrer"&gt;NVIDIA CDI support&lt;/a&gt;). A count of &lt;code&gt;0&lt;/code&gt; while the library mounts are still present is your answer: the container received the userspace stack and no hardware, so &lt;code&gt;nvidia-smi&lt;/code&gt; exits 9, the NVML code for driver-not-loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways a claim ends up with no device nodes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The node has never had them. Headless machine, no X server, no container that ran &lt;code&gt;nvidia-smi&lt;/code&gt;, no udev rule doing the work at boot. The plugin prepares a claim against a &lt;code&gt;/dev&lt;/code&gt; that has no NVIDIA entries in it.&lt;/li&gt;
&lt;li&gt;The node rebooted. The device files are not persistent across boot on a headless host, so a node that worked yesterday can come back with the same empty &lt;code&gt;/dev&lt;/code&gt; and re-arm the bug on the next pod.&lt;/li&gt;
&lt;li&gt;Something created them afterwards. Once the plugin has prepared a claim, the spec is cached, so device files that appear later do not reach that claim. This is why the bug is a race and not a static misconfiguration, and why the timing looks random across a node pool.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Recover the stuck pod, in this order
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;4. Create the device nodes on the host.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nvidia-modprobe &lt;span class="nt"&gt;-c&lt;/span&gt; 0 &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/nvidia&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-c 0&lt;/code&gt; creates the device file for minor number 0 and &lt;code&gt;-u&lt;/code&gt; loads &lt;code&gt;nvidia-uvm&lt;/code&gt; and creates its node. Running &lt;code&gt;nvidia-smi -L&lt;/code&gt; has the same side effect, which is how "it started working after I SSH'd in to check" becomes such a misleading bug report on this failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Invalidate the cached spec.&lt;/strong&gt; Step 4 on its own does not fix the running pod, because a prepared claim is tracked in two places the host has no idea about: the per-claim spec under &lt;code&gt;/var/run/cdi&lt;/code&gt;, and a &lt;code&gt;PreparedClaims&lt;/code&gt; entry in &lt;code&gt;checkpoint.json&lt;/code&gt; under &lt;code&gt;/var/lib/kubelet/plugins/gpu.nvidia.com/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
kubectl delete resourceclaim &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAIM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# skip if it came from a ResourceClaimTemplate&lt;/span&gt;
kubectl rollout restart daemonset/dra-driver-nvidia-gpu-kubelet-plugin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; dra-driver-nvidia-gpu
kubectl rollout status daemonset/dra-driver-nvidia-gpu-kubelet-plugin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; dra-driver-nvidia-gpu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete the workload first so &lt;code&gt;NodeUnprepareResources&lt;/code&gt; runs and removes the stale spec instead of orphaning it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Recreate the workload and repeat step 3&lt;/strong&gt; against the new claim UID. The grep count should now be non-zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the node create its device nodes before kubelet starts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;7.&lt;/strong&gt; The durable fix is ordering, since the bug is a race. A oneshot unit that runs ahead of kubelet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/nvidia-device-nodes.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Create NVIDIA character device nodes before kubelet starts&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;systemd-modules-load.service&lt;/span&gt;
&lt;span class="py"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;kubelet.service&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/nvidia-modprobe -c 0 -u&lt;/span&gt;
&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nvidia-device-nodes.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On multi-GPU nodes, repeat &lt;code&gt;-c&lt;/code&gt; per minor number. Put this in the node image or the bootstrap script rather than a runbook, because a fix that depends on someone reading a runbook after a reboot is not a fix. I would not assume &lt;code&gt;nvidia-persistenced&lt;/code&gt; covers this for you either: its own startup expects the device files to exist, so verify against the driver version you actually run before you drop the unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the container sees the GPU
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; nvidia-smi &lt;span class="nt"&gt;-L&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect one &lt;code&gt;GPU 0: NVIDIA ... (UUID: GPU-...)&lt;/code&gt; line per allocated device and exit status 0. Then check the container's view against the claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get resourceclaim &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAIM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.allocation.devices.results[*].device}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The device count from the claim and the line count from &lt;code&gt;nvidia-smi -L&lt;/code&gt; have to agree. On the host, &lt;code&gt;nvidia-ctk cdi list&lt;/code&gt; should enumerate the &lt;code&gt;nvidia.com/gpu=*&lt;/code&gt; devices, confirming the toolkit itself sees hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Running &lt;code&gt;nvidia-smi&lt;/code&gt; on the host and declaring victory.&lt;/strong&gt; It creates the device files and changes nothing for an already-prepared claim, because the cached spec is not regenerated. The pod keeps failing and the operator concludes the driver is flaky.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Working the generic fix list.&lt;/strong&gt; DKMS rebuilds, &lt;code&gt;nvidia-uvm&lt;/code&gt; reloads, Secure Boot checks and driver reinstalls all target a broken host driver. If &lt;code&gt;nvidia-smi -L&lt;/code&gt; works on the node, every one of them is wasted time, and &lt;code&gt;grep -c '/dev/nvidia'&lt;/code&gt; on the claim spec is what tells you so in one second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misfiling a UVM-only gap.&lt;/strong&gt; If &lt;code&gt;/dev/nvidiactl&lt;/code&gt; and &lt;code&gt;/dev/nvidia0&lt;/code&gt; are present but &lt;code&gt;/dev/nvidia-uvm&lt;/code&gt; is missing, &lt;code&gt;nvidia-smi&lt;/code&gt; succeeds and CUDA initialization fails instead. Different symptom, different fix, and the &lt;code&gt;-u&lt;/code&gt; flag in step 4 is what covers it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blaming the kubelet.&lt;/strong&gt; The plugin ships as a DaemonSet, so node-level state surfaces as a workload failure two layers away from its cause, the same indirection as &lt;a href="///blog/fix-kubelet-not-run-on-a-host-using-cgroup-v1-on-v2.html"&gt;the kubelet's cgroup v1 check misreading a v2 host&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does nvidia-smi work on the host but not in the pod?&lt;/strong&gt; The host has a working driver and the container has a CDI spec with no device nodes in it. The binary and NVML are mounted in, the hardware is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does restarting the pod fix it?&lt;/strong&gt; Only if the claim is deleted too. A restart against the same prepared claim reuses the cached spec under &lt;code&gt;/var/run/cdi&lt;/code&gt; and fails identically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where is the CDI spec for a DRA claim?&lt;/strong&gt; &lt;code&gt;/var/run/cdi/k8s.gpu.nvidia.com-claim_&amp;lt;claim-uid&amp;gt;.yaml&lt;/code&gt;, with a matching &lt;code&gt;PreparedClaims&lt;/code&gt; entry in &lt;code&gt;checkpoint.json&lt;/code&gt; under &lt;code&gt;/var/lib/kubelet/plugins/gpu.nvidia.com/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this fixed in a released version?&lt;/strong&gt; Not as of 15 September 2026. Issue #1380 is open against v0.5.0 with the v0.5.1 milestone attached, and v0.5.1 has not shipped. Check the releases page before you assume you still need the workaround.&lt;/p&gt;

&lt;h2&gt;
  
  
  One grep tells you which failure you have
&lt;/h2&gt;

&lt;p&gt;The whole diagnosis is &lt;code&gt;grep -c '/dev/nvidia'&lt;/code&gt; against the claim's CDI spec. Zero means the spec is device-less and the host driver is a red herring. Non-zero and a still-broken pod means you have an actual driver problem and the generic advice applies after all. Everything above is what you do on either side of that number.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/fix-nvidia-smi-has-failed-in-a-kubernetes-dra-pod.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
    </item>
    <item>
      <title>MCP Tool Permissions: 10 Tips to Prove Agent Write Access</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Mon, 14 Sep 2026 02:06:15 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/mcp-tool-permissions-10-tips-to-prove-agent-write-access-2gpf</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/mcp-tool-permissions-10-tips-to-prove-agent-write-access-2gpf</guid>
      <description>&lt;p&gt;Short version: in the 2026-07-28 MCP revision a server's tool list may legally vary by the token on the request, which turns &lt;code&gt;tools/list&lt;/code&gt; into an authorization surface instead of a static manifest. &lt;code&gt;readOnlyHint&lt;/code&gt; remains a self-declared annotation with no enforcement behind it, so your risk tiers belong in an allowlist you version. These ten tips are for the person who already has MCP servers in production, an agent calling them, and nothing to hand an auditor who asks what the agent can write to.&lt;/p&gt;

&lt;p&gt;Three mechanisms in that revision carry authorization consequences and nothing connects them in the vendor docs: token-varying tool lists, &lt;code&gt;cacheScope&lt;/code&gt; on those lists, and &lt;code&gt;x-mcp-header&lt;/code&gt;, which mirrors tool parameters into HTTP headers. A model trained before July will describe the old shape, where the tool list is a fixed property of the server. Getting a correctly audienced token in the first place is a separate job covered in &lt;a href="///blog/fix-mcp-oauth-2-1-before-the-july-28-rewrite.html"&gt;fixing MCP OAuth 2.1 before the July 28 rewrite&lt;/a&gt;; if you are tracking this against a control framework, the renumbering in &lt;a href="///blog/owasp-llm-top-10-2026-fix-your-crosswalk-8-ids-moved.html"&gt;the OWASP LLM Top 10 2026 crosswalk&lt;/a&gt; moved the IDs you probably cite. What follows is the layer underneath both: what an authenticated agent may actually invoke.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  A["Agent"] --&amp;gt; G{"Gateway\nallowlist + scope check"}
  G --&amp;gt;|"read scope"| R["Read-tier tools"]
  G --&amp;gt;|"403 insufficient_scope\nstep-up required"| W["Write-tier tools"]
  G --&amp;gt;|"unlisted: deny + log"| D["Default deny"]
  R --&amp;gt; S["MCP server"]
  W --&amp;gt; S&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  The tips
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat &lt;code&gt;readOnlyHint&lt;/code&gt; as a claim, never as a control.&lt;/strong&gt; The &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/tools" rel="noopener noreferrer"&gt;spec's tools page&lt;/a&gt; carries a warning that clients "&lt;strong&gt;MUST&lt;/strong&gt; consider tool annotations to be untrusted unless they come from trusted servers," and the MCP project's &lt;a href="https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/" rel="noopener noreferrer"&gt;March 2026 post on annotations&lt;/a&gt; puts it plainly: an untrusted server can lie, and annotations "aren't enforcement." A policy engine that auto-approves on &lt;code&gt;readOnlyHint: true&lt;/code&gt; has delegated risk tiering to the party being tiered. Hold the tier in your own allowlist and let the server's annotation do one job, raising an alert when it drifts from what you recorded.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hash the tool surface and alert on the diff.&lt;/strong&gt; Servers may change their tool set at any time and announce it with &lt;code&gt;notifications/tools/list_changed&lt;/code&gt;, so the manifest you reviewed in March is not the one answering calls today. Baseline the name, description, &lt;code&gt;inputSchema&lt;/code&gt; and &lt;code&gt;annotations&lt;/code&gt; together, because a rug pull can move a tool from read to write without touching the name.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   curl &lt;span class="nt"&gt;-sX&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MCP_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   | jq &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s1"&gt;'[.result.tools[] | {name, description, annotations, inputSchema}]'&lt;/span&gt; | &lt;span class="nb"&gt;sha256sum&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Filter the tool list by the token rather than by client config.&lt;/strong&gt; This is the sanctioned mechanism and it is new. The spec states the tool set "&lt;strong&gt;MUST NOT&lt;/strong&gt; vary per-connection or as a side effect of other requests," then adds that it "&lt;strong&gt;MAY&lt;/strong&gt; vary by the authorization presented on the request, for example returning only the tools the caller's granted scopes permit, since credentials are per-request input, not connection state." A read-scoped agent should never see a write tool in its context window at all. Prove it by listing under two tokens and diffing the name arrays.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;cacheScope: "private"&lt;/code&gt; the day that filtering goes live.&lt;/strong&gt; The &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching" rel="noopener noreferrer"&gt;caching page's Security Considerations&lt;/a&gt; describe the trap directly: a &lt;code&gt;tools/list&lt;/code&gt; result marked &lt;code&gt;"public"&lt;/code&gt; "may be cached by a client and may be shared outside of the initial request's authorization context (i.e. different access tokens can leverage the same cache)." A scope-filtered list served as &lt;code&gt;public&lt;/code&gt; through a shared gateway hands the privileged tool set to every caller behind it. The same section warns that servers "&lt;strong&gt;MUST NOT&lt;/strong&gt; rely on &lt;code&gt;cacheScope&lt;/code&gt; alone to prevent unauthorized access," so treat it as a leak stopper sitting on top of a real check.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   ... | jq &lt;span class="s1"&gt;'.result | {cacheScope, ttlMs, count: (.tools|length)}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Key the allowlist on the canonical resource URI plus the tool name.&lt;/strong&gt; Tool names are unique only within a server. The spec notes that aggregating proxies "&lt;strong&gt;MAY&lt;/strong&gt; encounter naming collisions (for example, two servers each exposing a &lt;code&gt;search&lt;/code&gt; tool)," and that &lt;code&gt;serverInfo.name&lt;/code&gt; "is not guaranteed to be unique across servers and &lt;strong&gt;SHOULD NOT&lt;/strong&gt; be relied upon for disambiguation." An entry reading &lt;code&gt;search: allow&lt;/code&gt; is ambiguous by construction. Use the RFC 8707 canonical resource value the token was issued for, the same string you put in the &lt;code&gt;resource&lt;/code&gt; parameter.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;   &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mcp.example.com/mcp"&lt;/span&gt;   &lt;span class="c1"&gt;# the RFC 8707 resource value&lt;/span&gt;
     &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search"&lt;/span&gt;
     &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit &lt;code&gt;x-mcp-header&lt;/code&gt; on every tool schema you accept.&lt;/strong&gt; A parameter may carry &lt;code&gt;x-mcp-header&lt;/code&gt;, which mirrors its value into an &lt;code&gt;Mcp-Param-{name}&lt;/code&gt; HTTP header so load balancers and WAFs can route on it without parsing the body. The spec tells server developers they "&lt;strong&gt;SHOULD NOT&lt;/strong&gt; mark sensitive parameters (passwords, API keys, tokens, PII) with &lt;code&gt;x-mcp-header&lt;/code&gt;, as header values are visible to network intermediaries." It cuts both ways, and the second edge is useful: a gateway can deny on &lt;code&gt;Mcp-Param-Region&lt;/code&gt; with zero JSON parsing, which is the cheapest enforcement point you will find in this protocol.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   ... | jq &lt;span class="s1"&gt;'.result.tools[] | select([.inputSchema.properties[]?["x-mcp-header"]] | length &amp;gt; 0) | .name'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make writes earn a step-up and match on the exact 403 shape.&lt;/strong&gt; Issue the agent read scopes at session start and let the server challenge for anything more. The &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;authorization spec&lt;/a&gt; fixes the response format: &lt;code&gt;HTTP 403 Forbidden&lt;/code&gt; with &lt;code&gt;WWW-Authenticate: Bearer error="insufficient_scope", scope="files:write"&lt;/code&gt;, and it tells servers to emit every scope the operation needs in one challenge instead of trickling them out call by call. Assert on that header text in CI, since a gateway that returns 401 or a bare 403 will send your client into a retry loop.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bound the token lifetime, because granted scope only grows.&lt;/strong&gt; The step-up flow requires clients to compute "the union of the client's previously requested scope set and the scopes from the current challenge." Across a long-running session the agent accumulates permissions and sheds none of them, so expiry is the only thing that resets the union. That makes TTL an authorization control, and the reasoning is the same one behind &lt;a href="///blog/bound-serviceaccount-tokens-9-tips-to-kill-static-ones.html"&gt;killing static ServiceAccount tokens for bound ones&lt;/a&gt; in Kubernetes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Re-authorize stateful handles on every single call.&lt;/strong&gt; The sharpest line in the whole revision is the non-normative guidance on stateful tools: "a handle is a name, not a capability. The server should validate the caller's authorization against the handle on every call." For unauthenticated servers, "where the handle is necessarily a bearer token," the spec asks for UUIDv4-grade entropy and a bounded lifetime. Test it in ten minutes by creating a handle with token A, then replaying that handle with token B and confirming you get a denial rather than a result.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Decide your policy on &lt;code&gt;input_required&lt;/code&gt; before an agent meets one.&lt;/strong&gt; A &lt;code&gt;tools/call&lt;/code&gt; can return &lt;code&gt;resultType: "input_required"&lt;/code&gt; along with an &lt;code&gt;elicitation/create&lt;/code&gt; request; the spec's own example asks the user for a GitHub username. That is a server-controlled prompt arriving mid-call inside an automated loop, which is a prompt injection delivery path with a protocol blessing. Route elicitation to a human or fail the call, and never auto-fill it from a secret store. MRTR results also "&lt;strong&gt;MUST NOT&lt;/strong&gt; be cached," so these calls sidestep tip 4 entirely.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The habit that carries the rest: keep the risk tier in an artifact you version, and let the server's annotations only ever raise an alert. The scope-filtered list, the private cache scope, the step-up 403 and the bounded token all follow from refusing to let a tool grade its own homework, which is the same default-deny posture that keeps &lt;a href="///blog/stop-kubernetes-admission-control-failing-open-11-tips.html"&gt;Kubernetes admission control from failing open&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Turning this into evidence takes an afternoon. Write a CI job that lists tools under a read-scoped token, asserts zero write-tier names come back, then calls one of those write tools directly and asserts a 403 carrying &lt;code&gt;error="insufficient_scope"&lt;/code&gt;. That output is what goes in the review binder. The expensive part lands afterward: running the inventory across every MCP server your teams have quietly wired up, agreeing a tier vocabulary that survives contact with a second team, and standing up the gateway that enforces it. None of this addresses prompt injection, which remains a separate problem with separate controls.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/mcp-tool-permissions-10-tips-to-prove-agent-write-access.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tips</category>
    </item>
    <item>
      <title>Stop Kubernetes Admission Control Failing Open: 11 Tips</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Fri, 11 Sep 2026 05:08:39 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/stop-kubernetes-admission-control-failing-open-11-tips-12ok</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/stop-kubernetes-admission-control-failing-open-11-tips-12ok</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; an admission webhook with &lt;code&gt;failurePolicy: Ignore&lt;/code&gt; lets the request through when it times out, and the API server records that in &lt;code&gt;apiserver_admission_webhook_fail_open_count&lt;/code&gt; instead of denying anything. To prove enforcement you need three artifacts: the list of every &lt;code&gt;Ignore&lt;/code&gt; webhook with its timeout, an alert on that counter using &lt;code&gt;type="admit"&lt;/code&gt; for mutating webhooks, and the CEL-expressible rules moved into ValidatingAdmissionPolicy where there is no network call to lose.&lt;/p&gt;

&lt;p&gt;The reader I have in mind is the one who has to put the answer in writing. A control-effectiveness questionnaire asks whether privileged pods can be admitted. An SRE has to explain a pod that exists in a namespace where policy said it could not. The usual answer is a screenshot of Kyverno or Gatekeeper in &lt;code&gt;Enforce&lt;/code&gt; mode, which proves the policy object exists and says nothing about the seconds when the webhook behind it was unreachable. Admission control is also the wrong layer for a whole class of questions, so pair it with controls that keep working when the API server is busy: &lt;a href="///blog/kubernetes-default-deny-egress-stops-pod-exfiltration.html"&gt;a default-deny egress policy stops pod exfiltration&lt;/a&gt; regardless of what got admitted, and Kubernetes v1.37 tightened the control plane itself, which is why &lt;a href="///blog/fix-a-mirror-pod-may-not-reference-secrets-in-k8s-1-37.html"&gt;mirror pods may no longer reference secrets&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The path a request takes, and the branch that produces a written object with no denial:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  A["kubectl apply"] --&amp;gt; B["Mutating phase\nwebhooks + MutatingAdmissionPolicy"]
  B --&amp;gt; C{"Webhook answers\nin time?"}
  C --&amp;gt;|"no, failurePolicy Ignore"| F["Object written to etcd"]
  C --&amp;gt;|"yes"| D["Validating phase\nwebhooks + ValidatingAdmissionPolicy"]
  D --&amp;gt; F&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Before the list, the mapping worth keeping next to your policy repo. Rows where the evidence column reads "the policy exists" are the expensive ones.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control question&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Enforcement point&lt;/th&gt;
&lt;th&gt;Evidence you can query&lt;/th&gt;
&lt;th&gt;Blind window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Can a privileged pod be created?&lt;/td&gt;
&lt;td&gt;ValidatingAdmissionPolicy (CEL)&lt;/td&gt;
&lt;td&gt;In-process, kube-apiserver&lt;/td&gt;
&lt;td&gt;Audit annotation, or a denial&lt;/td&gt;
&lt;td&gt;CEL runtime error with &lt;code&gt;failurePolicy: Ignore&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is this image signed?&lt;/td&gt;
&lt;td&gt;Kyverno or Gatekeeper webhook&lt;/td&gt;
&lt;td&gt;External pod over the network&lt;/td&gt;
&lt;td&gt;Webhook logs plus the fail-open counter&lt;/td&gt;
&lt;td&gt;Webhook unreachable and &lt;code&gt;Ignore&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Do workloads created last year comply?&lt;/td&gt;
&lt;td&gt;Background scanning&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Policy report&lt;/td&gt;
&lt;td&gt;Everything between scans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can the enforcement be deleted?&lt;/td&gt;
&lt;td&gt;RBAC on &lt;code&gt;admissionregistration.k8s.io&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;API authorization&lt;/td&gt;
&lt;td&gt;RBAC listing, audit delete events&lt;/td&gt;
&lt;td&gt;Any holder of &lt;code&gt;delete&lt;/code&gt; or &lt;code&gt;*&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is policy active while the API server starts?&lt;/td&gt;
&lt;td&gt;Manifest-based admission config (KEP-5793)&lt;/td&gt;
&lt;td&gt;File on the control-plane node&lt;/td&gt;
&lt;td&gt;API server fails readiness on an invalid manifest&lt;/td&gt;
&lt;td&gt;Clusters before v1.37&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Eleven checks, and what each one proves
&lt;/h2&gt;

&lt;p&gt;Tips 1 to 4 establish what is enforced right now. Tips 5 to 8 name what each control refuses to answer. Tips 9 to 11 cover what survives a control-plane restart.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory every webhook permitted to fail open, with its timeout, before claiming anything is enforced.&lt;/strong&gt; The v1 API defaults &lt;code&gt;failurePolicy&lt;/code&gt; to &lt;code&gt;Fail&lt;/code&gt;, so every &lt;code&gt;Ignore&lt;/code&gt; in your cluster was chosen by a person or a Helm chart. Service-mesh injectors, certificate controllers and image-mutation sidecars ship &lt;code&gt;Ignore&lt;/code&gt; deliberately to avoid wedging the cluster, and that choice is defensible until the same configuration also carries your security rules. Every cluster I have run this against had at least one &lt;code&gt;Ignore&lt;/code&gt; nobody present remembered choosing:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
     | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | .metadata.name as $c | .webhooks[]
              | select(.failurePolicy == "Ignore")
              | [$c, .name, (.timeoutSeconds // 10)] | @tsv'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Alert on &lt;code&gt;apiserver_admission_webhook_fail_open_count&lt;/code&gt;, and use &lt;code&gt;type="admit"&lt;/code&gt; for mutating webhooks.&lt;/strong&gt; The counter increments each time a request is allowed through because the webhook errored while its policy said &lt;code&gt;Ignore&lt;/code&gt;. &lt;a href="https://github.com/kubernetes/kubernetes/pull/127898" rel="noopener noreferrer"&gt;Kubernetes PR #127898&lt;/a&gt; corrected the help text in October 2024: the &lt;code&gt;type&lt;/code&gt; label carries &lt;code&gt;admit&lt;/code&gt; or &lt;code&gt;validating&lt;/code&gt;, where the documentation had long promised &lt;code&gt;mutating&lt;/code&gt;. A dashboard filtered on &lt;code&gt;type="mutating"&lt;/code&gt; returns an empty series forever and reads as a clean bill of health, which is the worst possible failure mode for a control you are attesting to.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   sum by (name, type) (rate(apiserver_admission_webhook_fail_open_count[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat a fail-open spike as a signal to investigate, and know the one thing that inflates it.&lt;/strong&gt; &lt;a href="https://github.com/kubernetes/kubernetes/issues/118829" rel="noopener noreferrer"&gt;kubernetes/kubernetes#118829&lt;/a&gt; documents a client cancelling a request mid-webhook-call being counted as a fail-open event. Somebody hitting Ctrl-C on a slow &lt;code&gt;kubectl apply&lt;/code&gt; therefore moves the same counter that a real outage moves. Correlate against &lt;code&gt;apiserver_admission_webhook_request_total&lt;/code&gt; and the webhook pod's own readiness before you open an incident ticket.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Move every rule CEL can express into ValidatingAdmissionPolicy, because an in-process check has no network to lose.&lt;/strong&gt; VAP evaluates inside kube-apiserver, which shrinks the failure modes to CEL compile errors and runtime errors, and its &lt;code&gt;failurePolicy&lt;/code&gt; governs only those (&lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/" rel="noopener noreferrer"&gt;Kubernetes docs&lt;/a&gt;). Start with the rules that read a single object: privileged containers, &lt;code&gt;hostNetwork&lt;/code&gt;, missing resource limits, forbidden image registries. Those are the four that show up in questionnaires and they need no external state.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run &lt;code&gt;validationActions: [Audit]&lt;/code&gt; first, then hunt the annotation the audit log actually writes.&lt;/strong&gt; Failures in Audit mode land in the audit event under the key &lt;code&gt;validation.policy.admission.k8s.io/validation_failure&lt;/code&gt;, and that annotation is the queryable artifact you hand over as evidence of coverage before flipping to &lt;code&gt;Deny&lt;/code&gt;. The API rejects &lt;code&gt;Deny&lt;/code&gt; and &lt;code&gt;Warn&lt;/code&gt; together, so the useful pre-production pairing is &lt;code&gt;[Warn, Audit]&lt;/code&gt;: authors see the message, you keep the record.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'select(.annotations["validation.policy.admission.k8s.io/validation_failure"])
          | [.requestReceivedTimestamp, .user.username, .objectRef.namespace,
             .annotations["validation.policy.admission.k8s.io/validation_failure"]] | @tsv'&lt;/span&gt; audit.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write down what VAP is silent on in the same document that claims the win.&lt;/strong&gt; CEL in the API server makes no external calls and holds no state, so image signature verification, cross-resource lookups beyond a &lt;code&gt;paramKind&lt;/code&gt;, resource generation and cleanup all stay with Kyverno or Gatekeeper. So does the category of resources that already exist: admission control has never evaluated an object created before the policy was written, which is what background scanning covers, and what runtime detection covers after that. Deciding which sensor owns the post-admission half is its own exercise, and I have argued the trade-offs in &lt;a href="///blog/falco-vs-tetragon-vs-tracee-pick-the-right-one.html"&gt;Falco vs Tetragon vs Tracee&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;On Gatekeeper v3.20 or later you have two enforcement points, so confirm they share a scope.&lt;/strong&gt; VAP generation reached beta and default-on in v3.20 with &lt;code&gt;--default-create-vap-for-templates&lt;/code&gt; and &lt;code&gt;--default-create-vap-binding-for-constraints&lt;/code&gt; both &lt;code&gt;true&lt;/code&gt; (&lt;a href="https://open-policy-agent.github.io/gatekeeper/website/docs/validating-admission-policy/" rel="noopener noreferrer"&gt;Gatekeeper docs&lt;/a&gt;). The sharp edge: a VAP is generated only for ConstraintTemplates that carry the &lt;code&gt;K8sNativeValidation&lt;/code&gt; CEL engine, engine priority is fixed, and there is no fallback to Rego. A Rego-only template keeps running purely on the webhook while your dashboard reports VAP enabled.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   kubectl get constrainttemplates &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
     | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | [.metadata.name,
         ((.spec.targets[0].code // []) | map(.engine) | join(",") | if . == "" then "rego-only" else . end)] | @tsv'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design around the shape of &lt;code&gt;ApplyConfiguration&lt;/code&gt; before you retire a mutating webhook.&lt;/strong&gt; MutatingAdmissionPolicy has been stable since Kubernetes v1.36, and its &lt;code&gt;ApplyConfiguration&lt;/code&gt; patches use server-side apply semantics, which forbids modifying atomic structs, maps or arrays (&lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/mutating-admission-policy/" rel="noopener noreferrer"&gt;Kubernetes docs&lt;/a&gt;). Surgical edits to fields Kubernetes marks atomic need &lt;code&gt;JSONPatch&lt;/code&gt; instead. Set &lt;code&gt;reinvocationPolicy: IfNeeded&lt;/code&gt; where your policy has to re-run after another policy mutates the object, otherwise ordering decides your outcome.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prove that nobody outside the platform team can delete the enforcement.&lt;/strong&gt; A ValidatingAdmissionPolicyBinding is an ordinary API object: delete the binding and the policy stops applying, with no denial, no warning, and one audit entry that looks like housekeeping. Enumerate who holds &lt;code&gt;delete&lt;/code&gt; on the &lt;code&gt;admissionregistration.k8s.io&lt;/code&gt; group, wildcards included, then check which of those subjects are still authenticating with long-lived credentials (the same argument as &lt;a href="///blog/bound-serviceaccount-tokens-9-tips-to-kill-static-ones.html"&gt;killing static ServiceAccount tokens&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   kubectl get clusterroles &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
     .items[] | .metadata.name as $r | .rules[]?
     | select((.apiGroups[]? | . == "admissionregistration.k8s.io" or . == "*")
           and (.verbs[]? | . == "delete" or . == "*")) | $r'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Close the bootstrap and self-protection gaps with manifest-based admission config, beta in v1.37.&lt;/strong&gt; &lt;a href="https://www.kubernetes.dev/resources/keps/5793/" rel="noopener noreferrer"&gt;KEP-5793&lt;/a&gt; states that file-configured admission policies and webhooks must be active before the API server begins processing requests, and that manifest-based controls cannot be bypassed or modified through the Kubernetes API. Objects loaded this way carry a &lt;code&gt;.static.k8s.io&lt;/code&gt; name suffix, and an invalid manifest stops the API server reaching ready, which is a loud failure by design. Kubernetes v1.37 (Garhwal) was released on 26 August 2026, so treat this as a control-plane change with a rollback plan, tested on one control-plane node at a time.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apiserver.config.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AdmissionConfiguration&lt;/span&gt;
&lt;span class="na"&gt;staticManifestsDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/kubernetes/admission-static&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the coverage matrix, one row per question you will be asked.&lt;/strong&gt; Use the five columns in the table above: the control question, the mechanism, the enforcement point, the queryable evidence, and the window where it is blind. Filling it in takes a half day of reading manifests and produces a document that outlives every dashboard in this list, because the column that matters is the blind window and no tool populates it for you.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The habit that carries the rest: never accept "the policy is in Enforce mode" as evidence. Ask for the artifact, meaning the metric series, the audit annotation, or the RBAC list, and where no artifact exists, record the blind window in place of the reassurance.&lt;/p&gt;

&lt;p&gt;The counterargument is worth airtime. Teams running &lt;code&gt;failurePolicy: Fail&lt;/code&gt; on every webhook have paid for it with a cluster that refused all writes when the policy pod crash-looped, and &lt;code&gt;Ignore&lt;/code&gt; on the injector is how they stopped that recurring. That position buys availability and costs the ability to answer question one. Pick deliberately, write down which you picked, and date it.&lt;/p&gt;

&lt;p&gt;Tips 1 through 5 fit inside a week with the staff you have. Tips 7 through 10 touch the control plane, need a maintenance window, and need an owner for the evidence trail afterwards. When that second half keeps slipping, a scoped admission-control review is the usual way in: inventory every webhook and policy, separate the rules CEL can hold from the rules that need an external engine, produce the audit queries your control-effectiveness answers point at, and hand back the blind-window list with a cost against each fix. Somebody still has to approve the API server restart.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/stop-kubernetes-admission-control-failing-open-11-tips.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tips</category>
    </item>
    <item>
      <title>Pin GitHub Actions by SHA: 10 Gaps the Pin Leaves Open</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Fri, 11 Sep 2026 02:08:20 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/pin-github-actions-by-sha-10-gaps-the-pin-leaves-open-3agc</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/pin-github-actions-by-sha-10-gaps-the-pin-leaves-open-3agc</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; pinning &lt;code&gt;uses:&lt;/code&gt; to a full 40-character SHA stops a maintainer rewriting a tag under you, and it does nothing about a commit that entered the upstream repository legitimately. That is the path ChainDrop took on 4 August 2026 into packages with roughly two billion monthly installs, carrying cryptographically valid provenance the whole way. The ten checks below cover the rest; the first five are a week of existing-staff work.&lt;/p&gt;

&lt;p&gt;The reader I have in mind is the person who has to write the answer down. A customer security questionnaire asks whether third-party CI code is pinned and verified. An auditor asks for the artifact that enforces it. An incident channel asks whether a compromised package version was ever resolved into a build. Datadog's &lt;a href="https://www.datadoghq.com/blog/devsecops-2026-study-learnings/" rel="noopener noreferrer"&gt;State of DevSecOps 2026&lt;/a&gt; found 71% of organisations leave GitHub Actions completely unpinned and only 4% pin every public action to a commit hash, so most teams are answering those three questions from memory. The same trust assumption bites outside CI too: the &lt;a href="///blog/arrayref-attack-shows-cargo-build-rs-runs-any-code.html"&gt;arrayref attack showed cargo's build.rs runs any code&lt;/a&gt; the moment a legitimate version bump lands.&lt;/p&gt;

&lt;p&gt;Here is the path that beat the pin.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["Maintainer account compromised"] --&amp;gt; B["Malicious commit lands in keyv repo"]
  B --&amp;gt; C["Tag v6.0.0 points at that commit"]
  C --&amp;gt; D["Legitimate release.yaml workflow runs"]
  D --&amp;gt; E["OIDC exchange mints short-lived npm token"]
  E --&amp;gt; F["keyv@6.0.0 published with valid provenance"]
  F --&amp;gt; G{"Consumer runs npm install"}
  G --&amp;gt;|"provenance verifies"| H["preinstall stealer executes"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Datadog Security Labs timestamped the first unsigned commit at 09:02:37 UTC and the malicious publish at 09:35:00 UTC, a 32-minute window (&lt;a href="https://securitylabs.datadoghq.com/articles/npm-worm-compromises-popular-npm-packages/" rel="noopener noreferrer"&gt;full writeup&lt;/a&gt;). Snyk's analysis of the same compromise states the mechanism directly: the malicious source was present in the tagged repository state, so the legitimate release workflow built and attested the malicious artifact.&lt;/p&gt;

&lt;p&gt;Before the list, the mapping I ask every team to keep next to their workflows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What it asserts&lt;/th&gt;
&lt;th&gt;What it is silent on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SHA pin&lt;/td&gt;
&lt;td&gt;The bytes behind this ref did not change under a tag&lt;/td&gt;
&lt;td&gt;Whether the commit was reviewed, or lives in the repo you think it does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;actions.lock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The pinned commit is reachable from a branch in the named repo&lt;/td&gt;
&lt;td&gt;Actions reached via reusable workflows in other repositories&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;npm provenance&lt;/td&gt;
&lt;td&gt;Which workflow, at which commit, built the tarball&lt;/td&gt;
&lt;td&gt;Whether that commit was authorised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gh attestation verify --signer-workflow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A named workflow file produced this artifact&lt;/td&gt;
&lt;td&gt;What that workflow's own dependencies did at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Egress allowlist&lt;/td&gt;
&lt;td&gt;Where the runner is permitted to talk&lt;/td&gt;
&lt;td&gt;What the job does with secrets it already holds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Ten checks, and what each one actually proves
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirm every pinned SHA lives in the upstream repository, not in a fork of it.&lt;/strong&gt; GitHub resolves a commit against the shared object database of the entire fork network, so a hex string from anyone's fork answers a request to &lt;code&gt;actions/checkout&lt;/code&gt;. Alex Vaines demonstrated in March 2026 that a pull request can keep &lt;code&gt;uses: actions/checkout@&lt;/code&gt; unchanged, swap only the hex, and execute forked code while the reviewer sees an ordinary SHA bump (&lt;a href="https://www.vaines.org/posts/2026-03-24-the-comforting-lie-of-sha-pinning/" rel="noopener noreferrer"&gt;the write-up&lt;/a&gt;). zizmor has an audit for exactly this substitution:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   zizmor &lt;span class="nt"&gt;--min-severity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;medium .github/workflows/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The finding you want is &lt;code&gt;impostor-commit&lt;/code&gt;. Treat a hit as an incident, since there is no benign reason for a fork-only commit to appear in a &lt;code&gt;uses:&lt;/code&gt; line.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Adopt the Actions lockfile while it is still a preview.&lt;/strong&gt; GitHub's &lt;code&gt;gh-actions-lock&lt;/code&gt; extension writes &lt;code&gt;.github/workflows/actions.lock&lt;/code&gt; and enforces check 1 at runtime: a locked action must have a branch the commit is reachable from, and repository redirects are refused.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   gh extension &lt;span class="nb"&gt;install &lt;/span&gt;github/gh-actions-lock
   gh actions-lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is pre-1.0, the file format can change between releases, and it skips local &lt;code&gt;./&lt;/code&gt; action references, so onboard one low-traffic repository first and read the &lt;a href="https://github.com/github/gh-actions-lock" rel="noopener noreferrer"&gt;extension's own README&lt;/a&gt; before you wire it into required checks.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory unpinned uses across the org, then state what the inventory misses.&lt;/strong&gt; Two blind spots survive a per-repo scan: Docker-based actions pinned by image tag, and actions pulled in by reusable workflows that live in a different repository. zizmor's &lt;code&gt;unpinned-images&lt;/code&gt; audit covers the first; nothing covers the second automatically, so enumerate callers by hand.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   gh repo list ORG &lt;span class="nt"&gt;--limit&lt;/span&gt; 1000 &lt;span class="nt"&gt;--json&lt;/span&gt; nameWithOwner &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[].nameWithOwner'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; gh api &lt;span class="s2"&gt;"repos/{}/contents/.github/workflows"&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[].name'&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same discipline as &lt;a href="///blog/generate-an-sbom-with-syft-filter-grype-with-vex.html"&gt;generating an SBOM with Syft and filtering Grype with VEX&lt;/a&gt;: the list is only useful once you can say out loud which components it never sees.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make &lt;code&gt;gh attestation verify&lt;/code&gt; assert a specific signer.&lt;/strong&gt; The bare two-argument form accepts any workflow in any repository under the owner you named, which means an artifact built by a workflow nobody reviewed still passes. The flags that turn it into a gate, per the &lt;a href="https://cli.github.com/manual/gh_attestation_verify" rel="noopener noreferrer"&gt;gh manual&lt;/a&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   gh attestation verify ./dist/app.tar.gz &lt;span class="nt"&gt;--owner&lt;/span&gt; ORG &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;--signer-workflow&lt;/span&gt; ORG/platform/.github/workflows/release.yml &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;--predicate-type&lt;/span&gt; https://slsa.dev/provenance/v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check your CI logs for the short form today. In my experience it is usually there, copied from a quickstart during the week someone first enabled attestations.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep for the developer-machine execution paths, because the package manager stopped being the only one.&lt;/strong&gt; The keyv payload shipped a &lt;code&gt;.vscode/tasks.json&lt;/code&gt; with &lt;code&gt;"runOn": "folderOpen"&lt;/code&gt; and a &lt;code&gt;.claude/settings.json&lt;/code&gt; SessionStart hook, both invoking &lt;code&gt;setup.mjs&lt;/code&gt;, &lt;a href="https://snyk.io/blog/inside-keyv-npm-compromise-preinstall-malware-trusted-provenance-ide-hooks/" rel="noopener noreferrer"&gt;documented by Snyk&lt;/a&gt;. Opening the folder was sufficient; no install required.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   rg &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--hidden&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'**/.vscode/tasks.json'&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'**/.claude/settings.json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'"runOn"[[:space:]]*:[[:space:]]*"folderOpen"'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'"SessionStart"'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add both paths to CODEOWNERS in every repository. Ten minutes of work buys a permanent review requirement on the two files nobody reads in a diff. The install-time half of this problem is separate, and &lt;a href="///blog/npm-allowscripts-the-package-json-schema-not-npmrc.html"&gt;npm's allowScripts lives in the package.json schema rather than .npmrc&lt;/a&gt;, which trips up most teams enabling it for the first time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Split build from publish and put a human on the publish environment.&lt;/strong&gt; Keep &lt;code&gt;permissions: id-token: write&lt;/code&gt; in the publish job alone, then gate that job behind a GitHub Environment with required reviewers. A compromised push to the default branch then stops short of minting a registry token. The cost is honest: every release waits on an approver, which is a release-engineering trade rather than a free win. The token-scoping half of this is covered in the notes on &lt;a href="///blog/github-actions-oidc-to-aws-10-tips-to-kill-static-keys.html"&gt;GitHub Actions OIDC to AWS&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ask provenance only which workflow built the tarball.&lt;/strong&gt; npm provenance proves the workflow identity and the source commit, and it says nothing about whether that commit was authorised, which is precisely why the ChainDrop artifacts verified. Budget the authorisation question separately through install-time behavioural scanning or a cooldown window before new versions are allowed to resolve. Route on provenance, decide on behaviour.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Put hosted runners behind an egress allowlist you control now.&lt;/strong&gt; GitHub's Actions security roadmap, published 26 March 2026, lists a native egress firewall for hosted runners alongside dependency locking, scoped secrets and an immutable Actions data stream, and most of it is still preview. ChainDrop anchored its command-and-control in an Ethereum smart contract so that domain blocklists would rot, per Datadog, which settles the allowlist-versus-denylist argument for CI. Run one repository in block mode for two weeks and expect roughly a week of tuning; the &lt;a href="///blog/kubernetes-default-deny-egress-stops-pod-exfiltration.html"&gt;default-deny egress pattern for Kubernetes pods&lt;/a&gt; transfers almost directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Move the pinning rule into org-level Actions policy.&lt;/strong&gt; GitHub Actions policy has supported blocking specific actions and requiring SHA pinning since the 15 August 2025 changelog. Policy is an artifact an auditor accepts; a wiki page and a review convention are not. New repositories inherit the setting at creation, which is the part that survives your attention moving to something else.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Rehearse the "were we affected" answer against a real indicator set.&lt;/strong&gt; Datadog published a stage-two SHA-256 of &lt;code&gt;9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc&lt;/code&gt;, the contract address &lt;code&gt;0xE1f2395ee43e45A1556EC6438a88c31B83493103&lt;/code&gt;, and repositories named &lt;code&gt;Shai-Hulud: Here We Go Again&lt;/code&gt;. Test the lookup before an incident channel is waiting on it:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;ls &lt;/span&gt;keyv flat-cache file-entry-cache cacheable-request cacheable &lt;span class="se"&gt;\&lt;/span&gt;
  @cacheable/utils cache-manager ecto &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;The blind spot, stated plainly: that reads today's tree. Reconstructing which version a build resolved six months ago depends on retained run logs, and default Actions retention expires well before a slow-burn compromise surfaces. Anything a compromised runner could reach counts as exposed, including every secret your OIDC trust policies hand out at job start.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The habit that carries the rest: write one sentence next to each control saying what it asserts. "Pinned by SHA" asserts the bytes did not change under a tag. "Provenance verified" asserts which workflow built the artifact. Neither sentence contains the word "safe" or the word "reviewed", and the distance between what you believe you proved and what you actually proved is where the ChainDrop consumers were standing on 4 August.&lt;/p&gt;

&lt;p&gt;The counterargument deserves airtime. Maintainers who resist pinning point out that a fully pinned fleet stops receiving security patches the moment Dependabot coverage lapses, and stale pins have burned plenty of teams. That position buys real patch velocity, and it costs the ability to answer question one at all. Pick deliberately and record which you picked, with a date.&lt;/p&gt;

&lt;p&gt;Tips 1 through 5 fit inside a week with the staff you have. Tips 6 through 10 change release process, spend money on egress tooling, and need an owner for the evidence trail afterwards. When the second half keeps getting deferred, a scoped pipeline-provenance review is the usual way in: inventory every &lt;code&gt;uses:&lt;/code&gt; across the org, verify each pin against its upstream branch, produce the attestation policy your questionnaire answers can point at, and hand back the gap list with a cost attached to each fix. It will not make the finding disappear by itself. Someone still has to approve the release gate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/pin-github-actions-by-sha-10-gaps-the-pin-leaves-open.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tips</category>
    </item>
    <item>
      <title>Cyber Resilience Act Reporting: 10 Tips for Sept 11</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Wed, 09 Sep 2026 02:18:35 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/cyber-resilience-act-reporting-10-tips-for-sept-11-365i</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/cyber-resilience-act-reporting-10-tips-for-sept-11-365i</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; from 11 September 2026, &lt;a href="https://eur-lex.europa.eu/eli/reg/2024/2847/oj" rel="noopener noreferrer"&gt;Article 14 of Regulation (EU) 2024/2847&lt;/a&gt; obliges manufacturers of products with digital elements to report actively exploited vulnerabilities and severe incidents through ENISA's Single Reporting Platform on a 24-hour, 72-hour and 14-day clock. The obligation reaches products you placed on the market years ago, the platform ships without an API, and picking the wrong coordinating CSIRT invalidates the notification.&lt;/p&gt;

&lt;p&gt;This is for whoever owns product security at a company selling software, firmware or connected hardware into the EU. If your legal team has the CRA filed as a December 2027 problem, they are describing the conformity half. The reporting half starts in two days, and most of the operational work below is unglamorous inventory and timestamp discipline rather than engineering. If your release process already emits &lt;a href="///blog/generate-an-sbom-with-syft-filter-grype-with-vex.html"&gt;an SBOM per artifact&lt;/a&gt;, you are further along than you think.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["Manufacturer becomes aware"] --&amp;gt;|24h| B["Early warning via SRP"]
  B --&amp;gt; C["Coordinating CSIRT"]
  C --&amp;gt; D["ENISA"]
  C --&amp;gt; E["Other relevant CSIRTs"]
  A --&amp;gt;|without undue delay| F["Impacted users"]
  B --&amp;gt;|72h| G["Full notification"]
  G --&amp;gt;|14 days after fix| H["Final report"]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Ten tips before the clock starts
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory the products you stopped selling, because they are in scope too.&lt;/strong&gt; Article 69(3) of the Regulation extends Article 14 to every in-scope product placed on the market before 11 December 2027, with no carve-out for discontinued lines, end-of-life models or firmware nobody has touched since 2019, a reading the CRA-tracking site cyberresilienceact.eu &lt;a href="https://www.cyberresilienceact.eu/news/legacy-products-in-scope-cra-reporting-11-september-2026.html" rel="noopener noreferrer"&gt;spells out in detail&lt;/a&gt;. Those legacy products owe you nothing on Annex I essential requirements, conformity assessment or CE marking. They owe the report. Build one table and keep it where the incident runbook lives:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;| Product | Versions in the field | Legal entity that placed it | Build infra still exists? | Advisory channel |&lt;br&gt;
   |---|---|---|---|---|&lt;br&gt;
   | Gateway X | fw 4.2.1 to 4.7.0 | DE GmbH | yes | portal + email list |&lt;br&gt;
   | Sensor Y (EOL 2021) | fw 2.9.3 | DE GmbH | no | release notes only |&lt;/p&gt;

&lt;p&gt;The rows with "no" in column four are the ones that will hurt, and for those a binary scan of the shipped artifact is the only component list you are going to get, so decide &lt;a href="///blog/trivy-vs-grype-2026-pick-by-the-job-not-speed.html"&gt;which scanner you point at it&lt;/a&gt; before the incident. Auditing an old estate against a fresh obligation table is the same exercise as &lt;a href="///blog/owasp-llm-top-10-2026-fix-your-crosswalk-8-ids-moved.html"&gt;repointing a control crosswalk when the IDs move&lt;/a&gt;: the table is cheap, and the value is in the query that finds every row you forgot.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Determine your coordinating CSIRT on paper before you need it.&lt;/strong&gt; ENISA's &lt;a href="https://www.enisa.europa.eu/topics/product-security/single-reporting-platform-srp/frequently-asked-questions" rel="noopener noreferrer"&gt;Single Reporting Platform FAQ&lt;/a&gt; is blunt that selecting the wrong CSIRT designated as coordinator invalidates the notification, and the platform asks you for it at registration. The test is your main establishment, meaning where decisions about your products' cybersecurity are predominantly taken, which is frequently a different city from your registered office. Where it is genuinely unclear, the fallback chain runs through largest EU establishment, then authorised representative, then importer, then distributor, then user-base concentration. Write the reasoning down, date it, and check the Member State entry against ENISA's coordinating CSIRT list, &lt;a href="https://www.cyberresilienceact.eu/news/enisa-publishes-coordinating-csirt-list-4-september-2026.html" rel="noopener noreferrer"&gt;published on 4 September 2026&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create EU Login accounts with MFA today and register on the platform later.&lt;/strong&gt; Platform access runs on personal EU Login accounts with multi-factor authentication, with one Primary Assigned Representative per manufacturer who can invite up to 20 Secondary ARs. Account creation at &lt;code&gt;https://ecas.ec.europa.eu/cas/login&lt;/code&gt; needs no approval and takes minutes. Do it for a filer and at least one deputy now, so nobody is doing an authenticator enrolment at hour 19 of a 24-hour window.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Know that an unvalidated account is capped at 20 notifications.&lt;/strong&gt; ENISA runs manufacturer validation in parallel with reporting, so a pending validation does not block your first filing, per the same FAQ. The ceiling bites later: an unvalidated AR may submit up to 20 notifications before validation becomes mandatory. For a single-product manufacturer that limit is theoretical. For a large portfolio having a bad quarter it is a queue worth clearing while nothing is on fire.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Compose the notification outside the platform, then transcribe it.&lt;/strong&gt; There is no API at initial release, every field is typed by a human into a web form, and platform drafts are not visible to colleagues, so two people cannot co-author one report inside the tool. Keep a skeleton in the shared document your incident process already uses:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;   &lt;span class="na"&gt;notification_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;early warning&lt;/span&gt;
   &lt;span class="na"&gt;awareness_utc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;2026-09-14T22:41Z&lt;/span&gt;
   &lt;span class="na"&gt;awareness_basis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;PSIRT confirmed exploitation from customer PCAP&lt;/span&gt;
   &lt;span class="na"&gt;awareness_owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;&amp;lt;name&amp;gt;&lt;/span&gt;
   &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;             &lt;span class="s"&gt;&amp;lt;name&amp;gt; / firmware 4.2.1 to 4.7.0&lt;/span&gt;
   &lt;span class="na"&gt;coordinating_csirt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;&amp;lt;Member State CSIRT&amp;gt;, per main-establishment note 2026-09-05&lt;/span&gt;
   &lt;span class="na"&gt;suspected_malicious&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Record your own awareness timestamp, because the platform records a different one.&lt;/strong&gt; Article 14 starts the 24-hour clock when the manufacturer becomes aware, while early platform builds capture incident detection, and those two moments diverge whenever triage takes a day to conclude that exploitation is real. Keep a timestamped note of the moment your organisation formed that belief plus the evidence behind it. That note is what you show a regulator, and it is the field an auditor asks for first.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ignore the platform's 72-hour counter and run your own.&lt;/strong&gt; The counter displays a due date 48 hours after your 24-hour submission, so a report filed at hour 20 can show as overdue while you are still inside the legal window. Compute both deadlines from awareness and put them in the incident tracker:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# GNU coreutils date; both deadlines derive from awareness, never from submission&lt;/span&gt;
   &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'2026-09-14 22:41 UTC + 24 hours'&lt;/span&gt; +%Y-%m-%dT%H:%MZ   &lt;span class="c"&gt;# 2026-09-15T22:41Z&lt;/span&gt;
   &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'2026-09-14 22:41 UTC + 72 hours'&lt;/span&gt; +%Y-%m-%dT%H:%MZ   &lt;span class="c"&gt;# 2026-09-17T22:41Z&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Define "actively exploited" as a triage rule bound to signals you can query.&lt;/strong&gt; Severity alone does not fire the obligation; exploitation does. Article 14(5) defines a severe incident as one that negatively affects the product's ability to protect data or functions, or that has led to malicious code being introduced or executed, so translate that into named sources a duty engineer can check inside an hour: specific EDR or WAF rule IDs covering the affected code path, telemetry or honeypot hits on the vulnerable endpoint, customer-supplied indicators, and upstream PSIRT advisories for components you ship. If the runtime signal does not exist yet, that is a tooling decision to make now rather than during triage, and the &lt;a href="///blog/falco-vs-tetragon-vs-tracee-pick-the-right-one.html"&gt;Falco, Tetragon and Tracee tradeoffs&lt;/a&gt; determine which of those rule IDs you can even write. A trigger definition with no query behind it produces an argument at 2 a.m.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wire the user-notification duty into the same runbook.&lt;/strong&gt; Article 14(8) requires you to inform impacted users about the vulnerability or incident and the mitigations available, and the coordinating CSIRT may notify your users itself if you fail to do so in time. Your advisory channel has become a regulated control surface. Confirm you can publish a machine-readable advisory and reach the customers running affected versions on the same day you file the early warning; if the only distribution path is a release-notes page, that gap is the expensive one to discover live.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Separate the reporting budget from the conformity budget, and classify products anyway.&lt;/strong&gt; Reporting readiness is a runbook, a CSIRT determination and staffing. Conformity is Annex I engineering, technical documentation and CE marking, due 11 December 2027 for new and substantially modified products, with the Commission's &lt;a href="https://digital-strategy.ec.europa.eu/en/policies/cra-reporting" rel="noopener noreferrer"&gt;CRA reporting policy page&lt;/a&gt; as the canonical timeline. Classify each product now under &lt;a href="https://eur-lex.europa.eu/eli/reg_impl/2025/2392/oj" rel="noopener noreferrer"&gt;Commission Implementing Regulation (EU) 2025/2392&lt;/a&gt;, which fixes the technical descriptions of the important and critical categories and settles them by core functionality, since that classification decides your 2027 assessment route and costs nothing to establish today. In my experience the split matters organisationally as much as financially: the reporting work lands on the security team and the conformity work lands on product engineering, on different clocks.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The honest counterargument: a small manufacturer with one product and one EU establishment can do tips 1 through 3 in an afternoon and defer the rest until something happens. That position buys real time back, and it is defensible. It costs you the weekend coverage question, because the 24-hour window is wall clock and includes Saturdays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The habit that matters is the timestamp. Everything else here is paperwork you can produce under pressure, but the moment your organisation became aware cannot be reconstructed after the fact, and it is the single input deciding whether a report was late. Add an &lt;code&gt;awareness_utc&lt;/code&gt; field and a mandatory &lt;code&gt;awareness_basis&lt;/code&gt; line to your incident template this week, populate them during triage instead of during the write-up, and derive both deadlines from that field with the &lt;code&gt;date -u -d&lt;/code&gt; one-liner above rather than from whatever the platform displays. Cheap control, and it only works if it is already running on the bad day.&lt;/p&gt;

&lt;p&gt;Scoping a product inventory across a legacy portfolio, writing the main-establishment memo and rehearsing a filing against a platform with no API is a few weeks of work for someone who has done it before, and it is the point where reading stops being enough.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/cyber-resilience-act-reporting-10-tips-for-sept-11.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tips</category>
    </item>
    <item>
      <title>OWASP LLM Top 10 2026: Fix Your Crosswalk, 8 IDs Moved</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Tue, 08 Sep 2026 03:07:58 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/owasp-llm-top-10-2026-fix-your-crosswalk-8-ids-moved-242g</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/owasp-llm-top-10-2026-fix-your-crosswalk-8-ids-moved-242g</guid>
      <description>&lt;p&gt;OWASP published the &lt;a href="https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/" rel="noopener noreferrer"&gt;Top 10 for LLM Applications 2026 (v1.0)&lt;/a&gt; on 3 August 2026, and eight of the ten identifiers now point at a different risk than they did in 2025. Only LLM01 and LLM02 survived unchanged. Every crosswalk, policy clause, and customer questionnaire answer that cites a bare &lt;code&gt;LLM0x&lt;/code&gt; is now ambiguous, and the risk that moved furthest, Excessive Agency from #6 to #3, is the one your auditor will push hardest on. These tips are for the person who has to answer the questionnaire, defend the control set at renewal, or sign the ISO 42001 statement of applicability for a system that holds credentials and calls tools, the surface I wrote about in &lt;a href="///blog/fix-mcp-oauth-2-1-before-the-july-28-rewrite.html"&gt;MCP OAuth 2.1&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bare ID&lt;/th&gt;
&lt;th&gt;Meant in 2025&lt;/th&gt;
&lt;th&gt;Means in 2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LLM01&lt;/td&gt;
&lt;td&gt;Prompt Injection&lt;/td&gt;
&lt;td&gt;Prompt Injection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM02&lt;/td&gt;
&lt;td&gt;Sensitive Information Disclosure&lt;/td&gt;
&lt;td&gt;Sensitive Information Disclosure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM03&lt;/td&gt;
&lt;td&gt;Supply Chain&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Excessive Agency&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM04&lt;/td&gt;
&lt;td&gt;Data and Model Poisoning&lt;/td&gt;
&lt;td&gt;Supply Chain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM05&lt;/td&gt;
&lt;td&gt;Improper Output Handling&lt;/td&gt;
&lt;td&gt;Data and Model Poisoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM06&lt;/td&gt;
&lt;td&gt;Excessive Agency&lt;/td&gt;
&lt;td&gt;Unbounded Consumption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM07&lt;/td&gt;
&lt;td&gt;System Prompt Leakage&lt;/td&gt;
&lt;td&gt;Misinformation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM08&lt;/td&gt;
&lt;td&gt;Vector and Embedding Weaknesses&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hidden Context Exposure&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM09&lt;/td&gt;
&lt;td&gt;Misinformation&lt;/td&gt;
&lt;td&gt;Vector and Embedding Weaknesses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM10&lt;/td&gt;
&lt;td&gt;Unbounded Consumption&lt;/td&gt;
&lt;td&gt;Improper Output Handling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One warning before the list: any assistant trained before August will answer you in 2025 numbering with complete confidence, so verify every ID against the resource page above rather than against a chat window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tips
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Grep your policy estate for bare IDs and count them before you promise a timeline.&lt;/strong&gt; The remap cost is a number you can produce in a minute, and producing it first turns "we should update that" into scoped work. Run this across policies, contracts, SOA spreadsheets exported to text, and your questionnaire answer library:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rg &lt;span class="nt"&gt;-Pc&lt;/span&gt; &lt;span class="s1"&gt;'LLM(0[1-9]|10)\b(?!:20)'&lt;/span&gt; &lt;span class="nt"&gt;--glob&lt;/span&gt; &lt;span class="s1"&gt;'!node_modules'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;: &lt;span class="nt"&gt;-k2&lt;/span&gt; &lt;span class="nt"&gt;-nr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;The &lt;code&gt;(?!:20)&lt;/code&gt; lookahead skips anything already year-qualified as &lt;code&gt;LLM06:2025&lt;/code&gt;, so what remains is exactly the set of citations that changed meaning under you. &lt;code&gt;-P&lt;/code&gt; is required for the lookahead; without it ripgrep refuses the pattern. Fix each hit by adding the year, never by bumping the number, because the old clause described the old risk and your evidence was collected against it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Decide which OWASP list you are being assessed against, and say so in writing.&lt;/strong&gt; OWASP now maintains the LLM Applications Top 10 2026 (LLM01 to LLM10) and a separate &lt;a href="https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/" rel="noopener noreferrer"&gt;Top 10 for Agentic Applications 2026&lt;/a&gt; (ASI01 to ASI10), the latter released 9 December 2025 per OWASP's own resource page. The agentic list covers goal hijack, tool misuse, privilege abuse, memory poisoning, cascading failures, and rogue agents, all of which only exist once the model holds credentials. If you ship agents and answer only against the LLM list, you have answered the easier question and the assessor will find that out later.&lt;br&gt;
&lt;/p&gt;
&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["What are you being asked to prove?"] --&amp;gt; B{"Does the system call tools or act?"}
  B --&amp;gt;|"No: prompt in, text out"| C["LLM Top 10 2026 v1.0\nLLM01-LLM10, 3 Aug 2026"]
  B --&amp;gt;|"Yes: goals, memory, credentials"| D["Agentic Top 10 2026\nASI01-ASI10, 9 Dec 2025"]
  D --&amp;gt; E{"Can you show runtime evidence?"}
  E --&amp;gt;|"No"| F["Agent Control Standard v0.1\nAgBOM plus OTel/OCSF traces"]
  E --&amp;gt;|"Yes"| G["Map each ASI to one queryable artifact"]
  C --&amp;gt; G&lt;/code&gt;&lt;/pre&gt;


&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Know why Excessive Agency moved, because it changes what counts as evidence.&lt;/strong&gt; The 2026 ranking weighted expert vote at 75% and real incident data at 25%, drawn from 6,639 classifiable records out of 7,714 pulled from public vulnerability and AI-harm databases, as &lt;a href="https://www.helpnetsecurity.com/2026/08/06/owasp-2026-llm-top-10-released/" rel="noopener noreferrer"&gt;Help Net Security&lt;/a&gt; and &lt;a href="https://www.reversinglabs.com/blog/owasp-top-10-for-llm-apps-excessive-agency" rel="noopener noreferrer"&gt;ReversingLabs&lt;/a&gt; both reported from the launch. Excessive Agency climbed on incident counts, so reassurance about your design intent reads poorly against it. Bring numbers from the runtime layer you already operate: tool calls per session, blocked write attempts, approvals requested and denied. If you have no such layer yet, &lt;a href="///blog/falco-vs-tetragon-vs-tracee-pick-the-right-one.html"&gt;Falco, Tetragon, and Tracee&lt;/a&gt; are where the syscall-level half of that evidence comes from.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Retire prompt-filter percentages as your answer to LLM03:2026.&lt;/strong&gt; OWASP's framing at launch was that you build the system around the model so that when it is fooled, and it will be, nothing important breaks. A filter accuracy figure answers a containment question with a prevention number, and reviewers who read the 2026 text notice. Show four limits instead: a per-session tool-call ceiling, a per-agent spend cap, a wall-clock timeout, and a circuit breaker that trips on consecutive tool failures.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;LIMITS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tool_calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; \
   &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spend_usd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;LIMITS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_spend_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; \
   &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consecutive_tool_errors&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AgentHalted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# emit an OCSF event, page nobody, fail closed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;The network half of the same control is a &lt;a href="///blog/kubernetes-default-deny-egress-stops-pod-exfiltration.html"&gt;default-deny egress policy&lt;/a&gt; on the namespace the agent runs in, which caps the damage of a tool call you did not anticipate.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat LLM08 Hidden Context Exposure as a scope expansion for DLP and re-run classification.&lt;/strong&gt; System Prompt Leakage was retired and replaced by Hidden Context Exposure, which covers everything assembled into the window: retrieved documents, agent memory, tool schemas, tool responses, and application state. The item most teams forget is the tool schema. Parameter descriptions routinely carry internal endpoint names, table names, and business rules, and all of it leaves on a single "list your tools" turn. Dump your registered schemas and read them as an attacker would before you claim coverage.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instrument the context assembler so the LLM08 answer is a query.&lt;/strong&gt; A reviewer asking what your model can leak deserves a search result rather than a paragraph. Emit one span event per injected source carrying a classification label, then answer by querying for any trace where a source labelled &lt;code&gt;internal&lt;/code&gt; appears in a session whose response crossed the tenant boundary. Without that event you can only assert, and an assertion is what gets you a finding at the next audit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do not accept output sanitisation as a vendor's headline control.&lt;/strong&gt; Improper Output Handling fell from #5 to #10. It stayed on the list for good reason, since model output still reaches shells, SQL, and browsers, but a pitch that leads with output filtering is selling you coverage of the tenth-ranked risk while the third-ranked one sits unowned. Ask where the agency limits live, who can raise them, and what the audit trail looks like when someone does, before you ask about filters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Answer ASI03 with per-agent identity and be ready for the delegation follow-up.&lt;/strong&gt; Identity and Privilege Abuse is where most agent programmes fail an assessment, because one shared service account backs every agent in the fleet. The defensible shape is a distinct workload identity per agent plus a short-lived, user-scoped token per action, which is the same migration described in &lt;a href="///blog/bound-serviceaccount-tokens-9-tips-to-kill-static-ones.html"&gt;bound ServiceAccount tokens&lt;/a&gt;. Expect the assessor to ask what happens when agent A calls agent B, so have the token exchange path drawn before the call.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use the Agent Control Standard for inventory today and price its roadmap honestly.&lt;/strong&gt; ACS was donated to the OWASP GenAI Security Project and &lt;a href="https://genai.owasp.org/2026/09/01/owasp-genai-security-project-unveils-2026-top-10-for-llm-applications-new-agent-control-standard-and-sponsors-as-community-tops-30000-members/" rel="noopener noreferrer"&gt;announced on 1 September 2026&lt;/a&gt;, alongside the news that the community passed 30,000 members. Read &lt;a href="https://github.com/GenAI-Security-Project/agent-control-standard" rel="noopener noreferrer"&gt;the repository&lt;/a&gt; before you plan around it: the public preview is v0.1, Apache-2.0 for code and CC BY-SA 4.0 for docs, and it ships definitions plus observability schemas today, meaning OpenTelemetry and OCSF tracing and an Agent Bill of Materials expressed in CycloneDX, SWID, or SPDX. The published milestones put guardian-agent instrumentation at v1, AgBOM mappers at v2, and deny and modify operations at v3. Anyone selling ACS-based blocking this quarter is ahead of the specification. Adopt the AgBOM now as your inventory artifact, generated by the same tooling that already produces your &lt;a href="///blog/generate-an-sbom-with-syft-filter-grype-with-vex.html"&gt;Syft SBOMs&lt;/a&gt;, and keep enforcement where it currently works.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Give every risk one artifact and one query, then date-stamp the crosswalk.&lt;/strong&gt; Auditors accept "control, artifact, query" and argue with prose. Build the table as risk ID with year, control, artifact, and the exact command or saved search that produces it. Set two review dates rather than one, because the two lists run on different clocks: the agentic list shipped December 2025 and the LLM list August 2026, so a single annual review will always miss one of them.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does LLM06 still mean Excessive Agency?&lt;/strong&gt; No. In the 2026 list LLM06 is Unbounded Consumption. Excessive Agency is LLM03:2026. Cite the year and the name together so the sentence survives the next revision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which list applies if my app only summarises documents?&lt;/strong&gt; The LLM Applications Top 10 2026. The agentic list applies once the system pursues goals, keeps memory across turns, or holds credentials to act.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use the Agent Control Standard to block agent actions now?&lt;/strong&gt; Not from the standard itself. v0.1 ships definitions and observability schemas, with deny and modify operations scheduled for v3 in the published milestones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Write the year every time you cite an OWASP identifier. &lt;code&gt;LLM06&lt;/code&gt; was Excessive Agency for a year and is now Unbounded Consumption, and a contract clause that says only &lt;code&gt;LLM06&lt;/code&gt; will eventually be read by someone who was not in the room when it was written. Run the grep from tip 1 this week and publish the remap table internally, so you know the size of the job before a customer asks you to have already finished it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/owasp-llm-top-10-2026-fix-your-crosswalk-8-ids-moved.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tips</category>
    </item>
    <item>
      <title>kube-proxy IPVS to nftables: Fix 11 Traps in 1.40</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Mon, 07 Sep 2026 02:11:55 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/kube-proxy-ipvs-to-nftables-fix-11-traps-in-140-4l5n</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/kube-proxy-ipvs-to-nftables-fix-11-traps-in-140-4l5n</guid>
      <description>&lt;p&gt;Kubernetes 1.40 does two things to kube-proxy in the same release. &lt;a href="https://www.kubernetes.dev/resources/keps/5495/" rel="noopener noreferrer"&gt;KEP-5495&lt;/a&gt; flips the &lt;code&gt;KubeProxyIPVS&lt;/code&gt; feature gate to &lt;code&gt;Default: false&lt;/code&gt;, so IPVS mode stops starting unless you override it, and &lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-network/5343-nftables-to-default" rel="noopener noreferrer"&gt;KEP-5343&lt;/a&gt; makes &lt;code&gt;nftables&lt;/code&gt; the default proxy mode, so any cluster that never set &lt;code&gt;mode:&lt;/code&gt; gets moved underneath it. If you run IPVS today on a cluster with LoadBalancers, a NodeLocal DNSCache, and an alert rule someone wrote in 2021, the upgrade picks your dataplane for you.&lt;/p&gt;

&lt;p&gt;These eleven tips are the behaviour differences that show up in production rather than in the release notes, each with the command that finds it before the rollout does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 11 traps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Settle three inputs before you touch a ConfigMap, then run the decision per node pool.&lt;/strong&gt; Kernel version, whether your CNI already replaces kube-proxy, and how many Services you run determine the path, and they often differ between pools. If you are on Cilium with &lt;code&gt;kubeProxyReplacement&lt;/code&gt; enabled there is nothing to migrate, though the upgrade itself has &lt;a href="///blog/cilium-1-19-upgrade-fix-12-traps-before-bgp-drops.html"&gt;its own set of traps before BGP drops&lt;/a&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  A["kube-proxy mode: ipvs today"] --&amp;gt; B{"Kernel 5.13+ on every node?"}
  B --&amp;gt;|"No"| C["Set mode: iptables now\nbump the node image before 1.40"]
  B --&amp;gt;|"Yes"| D{"CNI replaces kube-proxy?"}
  D --&amp;gt;|"Yes, Cilium kubeProxyReplacement"| E["Delete the DaemonSet\nnothing to migrate"]
  D --&amp;gt;|"No"| F["Canary pool on mode: nftables\nthen fleet"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The "bump the node image" branch is a separate project with its own failure modes, including &lt;a href="///blog/fix-kubelet-not-run-on-a-host-using-cgroup-v1-on-v2.html"&gt;kubelet refusing to start with "not run on a host using cgroup v1"&lt;/a&gt; on a newer base image. Budget it as one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Price the escape hatch: &lt;code&gt;KubeProxyIPVS=true&lt;/code&gt; buys you exactly three releases.&lt;/strong&gt; KEP-5495 lays out the schedule: 1.35 logs a deprecation warning, 1.37 introduces the gate at &lt;code&gt;Default: true&lt;/code&gt;, 1.40 flips it to &lt;code&gt;false&lt;/code&gt; and kube-proxy exits with an error unless you set it back, 1.43 locks the gate and deletes &lt;code&gt;pkg/proxy/ipvs&lt;/code&gt;, 1.46 removes the gate entirely. Setting it in 1.40 is a legitimate stall with a hard expiry on the 1.43 upgrade, and there is no further extension to negotiate for.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write &lt;code&gt;mode:&lt;/code&gt; explicitly this week, even if you plan to stay on iptables.&lt;/strong&gt; KEP-5343 states the failure directly: a cluster on a kernel too old for nftables that upgrades to 1.40 "without having explicitly set &lt;code&gt;mode: iptables&lt;/code&gt; in their config" will start kube-proxy in nftables mode and fail. One line of config removes an entire class of upgrade-day incident, which is cheap next to the ones you find out about from a pager.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kube-system/kube-proxy ConfigMap, config.conf&lt;/span&gt;
&lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nftables"&lt;/span&gt;   &lt;span class="c1"&gt;# or "iptables", never blank&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Adding &lt;code&gt;--proxy-mode=nftables&lt;/code&gt; to the DaemonSet args does nothing.&lt;/strong&gt; When kube-proxy starts with &lt;code&gt;--config&lt;/code&gt;, command-line flags are ignored (kubernetes/kubernetes &lt;a href="https://github.com/kubernetes/kubernetes/issues/98302" rel="noopener noreferrer"&gt;#98302&lt;/a&gt;), and kubeadm's DaemonSet runs &lt;code&gt;--config=/var/lib/kube-proxy/config.conf&lt;/code&gt;. Check which one you have, then confirm the mode actually changed from the logs rather than trusting the rollout. Upgrade changes that silently no-op are a recurring shape in this ecosystem, in the same family as &lt;a href="///blog/fix-unrecognized-format-int32-in-kubernetes-1-34.html"&gt;&lt;code&gt;unrecognized format int32&lt;/code&gt; in 1.34&lt;/a&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get ds kube-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.template.spec.containers[0].command}'&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system logs ds/kube-proxy | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'proxier\|nftables'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the kernel on every node before you edit anything, because the ConfigMap is one object for the whole cluster.&lt;/strong&gt; nftables mode needs kernel 5.13+ and &lt;code&gt;nft&lt;/code&gt; 1.0.1+, and kube-proxy refuses to start below that. Sorting the Node objects by kernel version gives you the fleet answer in one command.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
NAME:.metadata.name,KERNEL:.status.nodeInfo.kernelVersion | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The blind spot: that string is what kubelet saw when it started, so a node that later rebooted into a rollback GRUB entry still reports the newer kernel. Spot-check those with &lt;code&gt;uname -r&lt;/code&gt; on the host. &lt;code&gt;nftables.skipKernelVersionCheck&lt;/code&gt; is a development option that skips the check without shipping a newer &lt;code&gt;nft&lt;/code&gt;, so it papers over the problem instead of solving it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;NodePort quietly stops answering on every node address except the primary.&lt;/strong&gt; iptables and IPVS default &lt;code&gt;nodePortAddresses&lt;/code&gt; to all local IPs; nftables defaults to &lt;code&gt;primary&lt;/code&gt;, meaning the node's primary IPv4 and IPv6 per the Node object (&lt;a href="https://github.com/kubernetes/kubernetes/pull/123105" rel="noopener noreferrer"&gt;PR #123105&lt;/a&gt;). A hardware load balancer pointed at a secondary NIC starts failing health checks the moment the DaemonSet rolls, and the Service looks healthy from inside the cluster the entire time. Keep the old behaviour deliberately, or list the CIDRs you actually front:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;nodePortAddresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0/0"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;::/0"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;127.0.0.1:&amp;lt;nodePort&amp;gt;&lt;/code&gt; is gone and will not come back as a default.&lt;/strong&gt; &lt;a href="https://github.com/kubernetes/enhancements/blob/master/keps/sig-network/3866-nftables-proxy/README.md" rel="noopener noreferrer"&gt;KEP-3866&lt;/a&gt; dropped loopback NodePort deliberately to avoid the &lt;code&gt;route_localnet&lt;/code&gt; sysctl, which is the CVE-2020-8558 surface. &lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-network/6032-nftables-localhost-nodeport-userspace-proxy" rel="noopener noreferrer"&gt;KEP-6032&lt;/a&gt; adds it back as an opt-in userspace TCP proxy behind &lt;code&gt;KubeProxyNFTablesLocalhostNodePorts&lt;/code&gt;: TCP only, rejecting UDP and SCTP, and only when loopback appears in &lt;code&gt;nodePortAddresses&lt;/code&gt;. Find the callers before the rollout by grepping host-network manifests, systemd units, and monitoring configs for &lt;code&gt;127.0.0.1:3[0-2][0-9][0-9][0-9]&lt;/code&gt;. The same inventory is worth having anyway when you get around to &lt;a href="///blog/kubernetes-default-deny-egress-stops-pod-exfiltration.html"&gt;default-deny egress&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expect intermittent "connection reset by peer" on long-lived connections, and answer it with &lt;code&gt;tcpBeLiberal&lt;/code&gt;.&lt;/strong&gt; iptables mode installs a DROP rule for packets that conntrack marks INVALID, and PR #120412 made that rule conditional so kube-proxy skips it when &lt;code&gt;--conntrack-tcp-be-liberal&lt;/code&gt; is set. nftables mode never installs the rule at all, which means the resets described in issue #117924 can resurface on connections that idle through a conntrack window. Watch the per-CPU counters on a canary node first, then set the option:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;conntrack &lt;span class="nt"&gt;-S&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;invalid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;conntrack&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tcpBeLiberal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your kube-proxy failure alert stops firing without ever going red.&lt;/strong&gt; &lt;code&gt;kubeproxy_sync_proxy_rules_iptables_restore_failures_total&lt;/code&gt; is exported in iptables and IPVS modes only; nftables mode exports &lt;code&gt;kubeproxy_sync_proxy_rules_nftables_sync_failures_total&lt;/code&gt; under a different name. A counter alert on a metric that stopped existing stays silent forever, which is worse than no alert because the dashboard still looks green. Rewrite the rule ahead of the rollout and pair it with an absence check so a future rename pages you:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;absent(kubeproxy_sync_proxy_rules_nftables_sync_failures_total) == 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Metrics are on &lt;code&gt;:10249/metrics&lt;/code&gt;, health on &lt;code&gt;:10256/healthz&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Scale-test at your real Service count, because nftables has a reported cliff.&lt;/strong&gt; kubernetes/kubernetes &lt;a href="https://github.com/kubernetes/kubernetes/issues/135639" rel="noopener noreferrer"&gt;#135639&lt;/a&gt;, opened 2025-12-06 against 1.32 to 1.34 on Amazon Linux 2 and 2023 and Bottlerocket, reports sync times of &lt;code&gt;13m15.34210662s&lt;/code&gt; at 1000+ Services alongside kernel soft lockups (&lt;code&gt;watchdog: BUG: soft lockup - CPU#13 stuck for 22s! [nft:222752]&lt;/code&gt;). Check the current state of that issue against your exact patch version, then measure rather than trust either side of the argument: record the p99 of &lt;code&gt;kubeproxy_sync_proxy_rules_duration_seconds&lt;/code&gt; on IPVS as your baseline and compare it on the canary pool under the same Service count.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Leave your NodeLocal DNSCache configuration exactly as it is.&lt;/strong&gt; The &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/" rel="noopener noreferrer"&gt;Kubernetes docs&lt;/a&gt; give two recipes: under IPVS the node-local-dns pod listens only on the link-local address and kubelet's &lt;code&gt;--cluster-dns&lt;/code&gt; points there, because the CoreDNS ClusterIP is already claimed by the &lt;code&gt;kube-ipvs0&lt;/code&gt; dummy interface. Under iptables and nftables the pod can additionally bind the ClusterIP. The IPVS-flavoured setup keeps working after the switch, and tidying it up means changing &lt;code&gt;--cluster-dns&lt;/code&gt; on every kubelet plus a restart, which is a second and larger migration hiding inside the first. Give it its own change ticket, after the dataplane is stable.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The objection you will actually have to argue is the scheduler one. KEP-5495 points out that many IPVS users have not realised the schedulers "aren't actually useful in Kubernetes": every node keeps its own IPVS table, so &lt;code&gt;lc&lt;/code&gt; counts connections on that node alone, and conntrack pins every subsequent packet of a flow regardless. A team that says "we need least-connection balancing" has been getting a per-node approximation of random for years. That is a real cost of leaving IPVS only if you run one node.&lt;/p&gt;

&lt;p&gt;Verify the switch instead of assuming it. After the canary rolls, &lt;code&gt;ipvsadm -Ln&lt;/code&gt; returns nothing, &lt;code&gt;ip link show kube-ipvs0&lt;/code&gt; reports the device is gone, and &lt;code&gt;nft list table ip kube-proxy&lt;/code&gt; prints a real ruleset. Hold the canary through one full traffic peak before touching the rest of the fleet, because the differences that hurt appear under load from a client nobody remembered.&lt;/p&gt;

&lt;p&gt;The habit worth adopting today is tip 3: pin &lt;code&gt;mode:&lt;/code&gt; explicitly in every cluster you own this week. It costs one line per cluster, and it decides whether you choose your dataplane or 1.40 chooses it for you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/kube-proxy-ipvs-to-nftables-fix-11-traps-in-1-40.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tips</category>
    </item>
    <item>
      <title>Fix docker save "no suitable export target found</title>
      <dc:creator>Indra Gusti Prasetya</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:08:45 +0000</pubDate>
      <link>https://dev.to/indra_gustiprasetya_a80a/fix-docker-save-no-suitable-export-target-found-pj5</link>
      <guid>https://dev.to/indra_gustiprasetya_a80a/fix-docker-save-no-suitable-export-target-found-pj5</guid>
      <description>&lt;p&gt;A five-platform image that pulled without a single warning, &lt;code&gt;docker image ls --tree&lt;/code&gt; printing &lt;code&gt;linux/amd64&lt;/code&gt; in the child rows, and the daemon refusing anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error response from daemon: no suitable export target found: image with reference
kserve/llmisvc-controller:v0.20.0 was found but does not provide the specified
platform (linux/amd64)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; under Docker 29's containerd image store, that one string covers four different states of your local store, and the docs describe exactly one of them. &lt;code&gt;docker buildx imagetools inspect&lt;/code&gt; tells you what the registry publishes, &lt;code&gt;docker image ls --tree&lt;/code&gt; tells you what your disk actually holds, and a &lt;code&gt;0B&lt;/code&gt; in the &lt;code&gt;CONTENT SIZE&lt;/code&gt; column is the difference between the two. The export that survives every cause is a pull by child digest.&lt;/p&gt;

&lt;p&gt;The report above is &lt;a href="https://github.com/docker/cli/issues/7278" rel="noopener noreferrer"&gt;docker/cli #7278&lt;/a&gt;, filed on 3 September 2026 against Docker 29.7.2 by an operator who wanted an amd64 tar for a Kind cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Docker 29 actually keeps when you pull one platform
&lt;/h2&gt;

&lt;p&gt;Docker Engine 29.0.0 (10 November 2025) made the containerd image store the default for fresh installs and added &lt;code&gt;--platform&lt;/code&gt; to &lt;code&gt;docker image save&lt;/code&gt; and &lt;code&gt;docker image load&lt;/code&gt; in the same release, per the &lt;a href="https://docs.docker.com/engine/release-notes/29/" rel="noopener noreferrer"&gt;29 release notes&lt;/a&gt;. Those two changes interact badly.&lt;/p&gt;

&lt;p&gt;Under the containerd store, a pull records the full OCI index and every child descriptor, then fetches blobs only for the platform you asked for. The manifest for &lt;code&gt;linux/s390x&lt;/code&gt; is on your disk. Its layers never arrived. Every tool that walks the index sees seven platforms; every tool that walks content sees one. The index-of-manifests shape is the same one that breaks registries in other places, which is why &lt;a href="///blog/cosign-v3-sign-and-verify-images-fix-harbor-breaks.html"&gt;Cosign v3 signatures start failing on Harbor&lt;/a&gt; and why scanner behaviour diverges per architecture when you are &lt;a href="///blog/trivy-vs-grype-2026-pick-by-the-job-not-speed.html"&gt;choosing between Trivy and Grype for a given job&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  R["Registry index\n5 platforms"] --&amp;gt;|pull one platform| S["Local content store"]
  S --&amp;gt; M["5 child manifests\nall recorded"]
  S --&amp;gt; B["Blobs for amd64 only\nother rows read 0B"]
  M --&amp;gt; T{"docker save\nplatform lookup"}
  B --&amp;gt; T
  T --&amp;gt;|platform absent from index| C1["Cause 1"]
  T --&amp;gt;|manifest yes blobs no| C2["Cause 2"]
  T --&amp;gt;|shared layer bug| C3["Cause 3"]
  T --&amp;gt;|flag omitted| C4["Cause 4"]
  T --&amp;gt;|export succeeds| X["tar is an OCI index"]
  X --&amp;gt;|ctr import all-platforms| K["content digest not found"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The bookkeeping says the platform exists and the storage disagrees. It is the same class of failure as &lt;a href="///blog/stop-kubernetes-dra-giving-one-gpu-to-a-vm-and-a-pod.html"&gt;Kubernetes DRA handing one GPU to a VM and a pod at once&lt;/a&gt;: the record of the resource and the resource itself drifted apart, and only one of them is authoritative when something tries to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four causes behind one string
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Error wording&lt;/th&gt;
&lt;th&gt;Separating command&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Platform never published&lt;/td&gt;
&lt;td&gt;&lt;code&gt;does not provide the specified platform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker buildx imagetools inspect &amp;lt;ref&amp;gt;&lt;/code&gt; omits it&lt;/td&gt;
&lt;td&gt;Build the variant yourself; there is nothing to export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Manifest present, blobs absent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;does not provide the specified platform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker image ls --tree&lt;/code&gt; shows &lt;code&gt;0B&lt;/code&gt; &lt;code&gt;0B&lt;/code&gt; on that row&lt;/td&gt;
&lt;td&gt;Pull the child manifest by digest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Shared-layer content-store bug&lt;/td&gt;
&lt;td&gt;&lt;code&gt;does not provide any platform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Two &lt;code&gt;--platform&lt;/code&gt; pulls sharing layers, then a push&lt;/td&gt;
&lt;td&gt;Pull by digest, or pull that image on its own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Host platform missing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NotFound: content digest sha256:...: not found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Failing command has no &lt;code&gt;--platform&lt;/code&gt; and the host arch row is &lt;code&gt;0B&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pass &lt;code&gt;--platform&lt;/code&gt; explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Cause 1&lt;/strong&gt; is the only one Docker documents. The &lt;a href="https://docs.docker.com/reference/cli/docker/image/save/" rel="noopener noreferrer"&gt;save reference&lt;/a&gt; states that "An error is produced if the given platform is not present in the local image store," which is accurate and almost never what you are hitting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause 2&lt;/strong&gt; is #7278, and before it &lt;a href="https://github.com/docker/cli/issues/6457" rel="noopener noreferrer"&gt;docker/cli #6457&lt;/a&gt; on 28.4.0, closed as not planned. The signal lives in &lt;code&gt;docker image ls --tree&lt;/code&gt;, still flagged experimental in the CLI. Child rows carry &lt;code&gt;DISK USAGE&lt;/code&gt; and &lt;code&gt;CONTENT SIZE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alpine:latest      beefdbd8a1da   10.6MB   3.37MB
├─ linux/riscv64   80cde017a105   10.6MB   3.37MB
├─ linux/amd64     33735bd63cf8   0B       0B
├─ linux/arm64/v8  9cee2b382fe2   0B       0B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two zeros mean a descriptor with no content behind it. The daemon's phrasing, "was found but does not provide the specified platform," is a statement about the reference lookup. Operators read it as a statement about the manifest, and the wording collapses two different facts into one sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause 3&lt;/strong&gt; is &lt;a href="https://github.com/moby/moby/issues/52897" rel="noopener noreferrer"&gt;moby #52897&lt;/a&gt;, carrying &lt;code&gt;status/confirmed&lt;/code&gt; against 29.5.3 with containerd v2.2.4. Pull &lt;code&gt;rancher/mirrored-coredns-coredns:1.14.3&lt;/code&gt; with &lt;code&gt;--platform linux/amd64&lt;/code&gt;, pull &lt;code&gt;rancher/mirrored-metrics-server:v0.8.1&lt;/code&gt; the same way, then tag and push the second one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;image with reference localhost:5000/test:0617 was found but does not provide any platform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull the second image on its own and it works. Shared layers between the two are the trigger, and the classic image store is unaffected. Watch the noun: &lt;em&gt;any&lt;/em&gt; platform instead of &lt;em&gt;the specified&lt;/em&gt; platform. That single word is the cheapest discriminator in the family and it costs you nothing to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause 4&lt;/strong&gt; is &lt;a href="https://github.com/moby/moby/issues/50173" rel="noopener noreferrer"&gt;moby #50173&lt;/a&gt;, a plain &lt;code&gt;docker save&lt;/code&gt; with no &lt;code&gt;--platform&lt;/code&gt; at all, on an amd64 host, after pulling only &lt;code&gt;linux/arm64/v8&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unable to create manifests file: NotFound: content digest sha256:0800…: not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reporter's reading is that the export path silently substitutes the host platform rather than exporting everything present. It sits in the maintainers' Containerd-as-default project with &lt;code&gt;kind/bug&lt;/code&gt;, next to &lt;a href="https://github.com/moby/moby/issues/44578" rel="noopener noreferrer"&gt;moby #44578&lt;/a&gt;, which records the same host-platform default in &lt;code&gt;docker image inspect&lt;/code&gt; and &lt;code&gt;docker image rm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is a fifth, mostly closed: release 29.6.0 (18 June 2026) shipped a fix for "image selection with the containerd image store on amd64 hosts when images provide amd64 variant-specific manifests." If you are below 29.6.0 and the publisher ships &lt;code&gt;linux/amd64/v3&lt;/code&gt;, upgrade before you triage anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one am I hitting?
&lt;/h2&gt;

&lt;p&gt;Two commands, in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. What does the registry actually publish?&lt;/span&gt;
docker buildx imagetools inspect kserve/llmisvc-controller:v0.20.0

&lt;span class="c"&gt;# 2. What does the local store actually hold?&lt;/span&gt;
docker image &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;--tree&lt;/span&gt;
docker image inspect &lt;span class="nt"&gt;--platform&lt;/span&gt; linux/amd64 kserve/llmisvc-controller:v0.20.0  &lt;span class="c"&gt;# API 1.49+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing from step 1 is cause 1. Present in step 1 with &lt;code&gt;0B&lt;/code&gt; in step 2 is cause 2. Real bytes in step 2 with an error that says "any platform" is cause 3. No &lt;code&gt;--platform&lt;/code&gt; on the failing command and a &lt;code&gt;0B&lt;/code&gt; host arch row is cause 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why kind load dies on a digest you never pulled
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;kind load docker-image&lt;/code&gt; shells out to &lt;code&gt;docker save&lt;/code&gt; and pipes the stream into &lt;code&gt;ctr --namespace=k8s.io images import --all-platforms --digests --snapshotter=overlayfs -&lt;/code&gt; inside the node container. &lt;code&gt;--all-platforms&lt;/code&gt; walks every child descriptor in the index, reaches the six with no blobs, and dies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ctr: content digest sha256:…: not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;a href="https://github.com/kubernetes-sigs/kind/issues/4066" rel="noopener noreferrer"&gt;kind #4066&lt;/a&gt; on kind 0.30 with Docker 29.1.2, and again on v0.32.0 in &lt;a href="https://github.com/kubernetes-sigs/kind/issues/4224" rel="noopener noreferrer"&gt;kind #4224&lt;/a&gt;. The digest belongs to an architecture the developer never asked for, which is why the error is close to ungoogleable for the person staring at it. Air-gapped delivery has the same shape (pull, save, ship the tar, load) with the same trap: the tar is now an OCI index, and the receiving runtime may or may not tolerate that. Local Docker dev loops accumulate these version-boundary failures, the same way &lt;a href="///blog/fix-pnpm-deploy-cannot-find-module-in-docker-on-11-19.html"&gt;pnpm deploy stopped finding modules inside Docker on 11.19&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is switching the store off a real fix?
&lt;/h2&gt;

&lt;p&gt;The workaround repeated across all these threads is &lt;code&gt;features.containerd-snapshotter=false&lt;/code&gt; in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;. It works, and it has a price nobody quotes. You move the daemon back to the store Docker is retiring, you strand every image already in the containerd store behind a wall you cannot see while the disk stays consumed, and you give up multi-platform handling.&lt;/p&gt;

&lt;p&gt;The engineer who disagrees runs a stable CI fleet on a pinned engine and wants zero variance until a maintenance window. That position is defensible for exactly as long as the pin holds, which in my experience is until someone rebuilds a runner image and picks up a fresh 29.x install with the containerd store back on by default. Buying six quiet months and paying for them with an unplanned migration is a trade worth making only if you have the migration scheduled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change before your next kind load
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check your engine version first.&lt;/strong&gt; Below 29.6.0, upgrade before triaging: the amd64 variant-selection fix landed there and will resolve a subset of these outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the noun in the error.&lt;/strong&gt; "the specified platform" points at content presence, cause 2. "any platform" is moby #52897 and needs a digest pull.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the two-command split&lt;/strong&gt; (&lt;code&gt;buildx imagetools inspect&lt;/code&gt;, then &lt;code&gt;image ls --tree&lt;/code&gt;) before changing any daemon config. &lt;code&gt;0B&lt;/code&gt; in &lt;code&gt;CONTENT SIZE&lt;/code&gt; is the only place the CLI states plainly that a platform row has no blobs behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin by child digest wherever you pinned by tag plus &lt;code&gt;--platform&lt;/code&gt;.&lt;/strong&gt; This is the form that survives all four causes and is more reproducible in CI regardless:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DIGEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker buildx imagetools inspect &lt;span class="nt"&gt;--raw&lt;/span&gt; kserve/llmisvc-controller:v0.20.0 &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.manifests[] | select(.platform.os=="linux" and .platform.architecture=="amd64") | .digest'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
docker pull kserve/llmisvc-controller@&lt;span class="nv"&gt;$DIGEST&lt;/span&gt;
docker save kserve/llmisvc-controller@&lt;span class="nv"&gt;$DIGEST&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; out.tar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;For kind, skip the wrapper&lt;/strong&gt; and drop the flag that causes the index walk:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker save redis:7-alpine | docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; kind-worker &lt;span class="se"&gt;\&lt;/span&gt;
  ctr &lt;span class="nt"&gt;--namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k8s.io images import &lt;span class="nt"&gt;--digests&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit every &lt;code&gt;pull → save → load&lt;/code&gt; path you own&lt;/strong&gt;, including air-gapped tar delivery. Grep your scripts for &lt;code&gt;--all-platforms&lt;/code&gt; on the import side; that flag is the trigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review scripts that key off &lt;code&gt;IMAGE ID&lt;/code&gt;.&lt;/strong&gt; &lt;a href="https://github.com/moby/moby/issues/51779" rel="noopener noreferrer"&gt;moby #51779&lt;/a&gt; reports that image IDs under the containerd store now derive from the index digest rather than the platform-specific config digest, so anything comparing IDs across a store migration will lie to you in the same maintenance window.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does &lt;code&gt;docker image ls --tree&lt;/code&gt; show a platform that &lt;code&gt;docker save&lt;/code&gt; says is missing?&lt;/strong&gt;&lt;br&gt;
The tree view walks index descriptors; the export path walks content. A pull records all child manifests and fetches blobs for one platform, so the row exists with &lt;code&gt;0B&lt;/code&gt; in both size columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between "does not provide the specified platform" and "does not provide any platform"?&lt;/strong&gt;&lt;br&gt;
The first means the daemon looked for one platform and found no content for it. The second is the confirmed shared-layer bug in moby #52897 on 29.5.3 with containerd v2.2.4, where an image with real content reports no platforms at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does disabling &lt;code&gt;containerd-snapshotter&lt;/code&gt; delete my images?&lt;/strong&gt;&lt;br&gt;
No. It hides them. The containerd store keeps its content on disk and consumes the space while the classic store is active, and the images reappear when you switch back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does &lt;code&gt;kind load docker-image&lt;/code&gt; fail with a digest I never pulled?&lt;/strong&gt;&lt;br&gt;
kind imports with &lt;code&gt;ctr images import --all-platforms&lt;/code&gt;, which iterates every child descriptor in the saved index, including the ones whose layers were never fetched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I pin a single-platform image in CI so this stops happening?&lt;/strong&gt;&lt;br&gt;
Resolve the child digest with &lt;code&gt;docker buildx imagetools inspect --raw&lt;/code&gt; plus &lt;code&gt;jq&lt;/code&gt;, then pull and save &lt;code&gt;image@sha256:...&lt;/code&gt;. The store then holds one platform with nothing dangling.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://indragustiprasetya.com/blog/fix-docker-save-no-suitable-export-target-found.html?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=article" rel="noopener noreferrer"&gt;indragustiprasetya.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
  </channel>
</rss>
