<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Open Source Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/open-source/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/open-source/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Fri, 31 Jul 2026 07:04:00 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.4</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>Open Source Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/open-source/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>Exit Code 0 Lied: The Silent Node.js Bug That Hit sarvam-cli Three Times</title>
		<link>https://linuxcent.com/nodejs-readline-silent-exit/</link>
					<comments>https://linuxcent.com/nodejs-readline-silent-exit/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 06:54:45 +0000</pubDate>
				<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[AI Agents]]></category>
		<category><![CDATA[CLI]]></category>
		<category><![CDATA[Debugging]]></category>
		<category><![CDATA[Node.js]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[readline]]></category>
		<category><![CDATA[sarvam-cli]]></category>
		<category><![CDATA[Secure Defaults]]></category>
		<category><![CDATA[Software Quality]]></category>
		<category><![CDATA[TypeScript]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=2295</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span>Fix the Node.js readline silent exit: why rl.question() never resolves when stdin closes, and how to make your CLI fail closed instead of exit 0.</p>
<p>The post <a href="https://linuxcent.com/nodejs-readline-silent-exit/">Exit Code 0 Lied: The Silent Node.js Bug That Hit sarvam-cli Three Times</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;<br />
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;<br />
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;<br />
    font-size:.88em;line-height:1.6;border-left:4px solid #555}<br />
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}<br />
pre code{background:transparent;padding:0;color:inherit}<br />
pre[data-lang="bash"],pre[data-lang="sh"],<br />
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}<br />
pre[data-lang="yaml"],pre[data-lang="json"],<br />
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}<br />
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],<br />
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}<br />
pre[data-lang="text"],pre[data-lang="output"],<br />
pre[data-lang="console"]{border-left-color:#888}<br />
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;<br />
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;<br />
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;<br />
    transition:opacity .15s,background .15s;line-height:1.6}<br />
pre:hover .lc-copy-btn{opacity:1}<br />
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}<br />
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}<br />
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;<br />
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;<br />
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}<br />
pre:hover .lc-lang-badge{opacity:1}<br />
table{border-collapse:collapse;width:100%;margin:16px 0}<br />
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}<br />
th{background:#f0f0f0;font-weight:600}<br />
tr:nth-child(even){background:#fafafa}<br />
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>11 min read</em></p>
<p>A Node.js readline silent exit happens when <code class="" data-line="">rl.question()</code> never fires its callback. The interface closes first, the awaited promise stays pending forever, and the event loop drains. As a result, the process exits cleanly with status 0 — mid-prompt, no error, no stack trace. I hit this in three separate places in <a href="https://github.com/indic-ai-contribs/sarvam-cli">sarvam-cli</a>, an MIT-licensed agentic coding CLI. The fix is eight lines. The interesting part is what each call site should do when input ends, and why &#8220;decline&#8221; is the only safe answer at an approval prompt.</p>
<h2 id="table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#tldr">TL;DR</a></li>
<li><a href="#the-symptom-a-at-the-end-of-a-terminal-paste">The Symptom: a % at the End of a Terminal Paste</a></li>
<li><a href="#why-my-first-diagnosis-was-wrong">Why My First Diagnosis Was Wrong</a></li>
<li><a href="#what-actually-causes-the-nodejs-readline-silent-exit">What Actually Causes the Node.js readline Silent Exit</a></li>
<li><a href="#why-exit-code-0-is-the-real-damage">Why Exit Code 0 Is the Real Damage</a></li>
<li><a href="#three-call-sites-one-copy-pasted-habit">Three Call Sites, One Copy-Pasted Habit</a></li>
<li><a href="#the-fix-resolve-to-a-sentinel-when-the-interface-closes">The Fix: Resolve to a Sentinel When the Interface Closes</a></li>
<li><a href="#what-null-should-mean-at-each-call-site">What null Should Mean at Each Call Site</a></li>
<li><a href="#why-a-partial-write-is-worse-than-no-write">Why a Partial Write Is Worse Than No Write</a></li>
<li><a href="#proving-it-testing-the-interactive-path-in-a-pty">Proving It: Testing the Interactive Path in a pty</a></li>
<li><a href="#quick-reference">Quick Reference</a></li>
<li><a href="#beyond-node-any-callback-with-a-path-that-never-runs">Beyond Node: Any Callback With a Path That Never Runs</a></li>
<li><a href="#cissp-domain-mapping">CISSP Domain Mapping</a></li>
<li><a href="#key-takeaways">Key Takeaways</a></li>
<li><a href="#try-sarvam-cli">Try sarvam-cli</a></li>
</ul>
<h2 id="tldr">TL;DR</h2>
<ul>
<li><code class="" data-line="">rl.question()</code> never fires its callback if the readline interface closes first. Ctrl+D, Ctrl+C, and exhausted piped stdin all close it.</li>
<li>A pending promise is not a running task. Node finds nothing scheduled, drains the loop, and exits with status 0 — so the failure presents as success.</li>
<li>The fix is to resolve to <code class="" data-line="">null</code> on close, not to reject. End of input is normal, not exceptional.</li>
<li>What <code class="" data-line="">null</code> <em>means</em> differs per call site: exit at a REPL prompt, <strong>decline</strong> at a consent gate, abort without writing in a config wizard.</li>
<li>At an approval prompt, <code class="" data-line="">=== &quot;y&quot;</code> fails closed and <code class="" data-line="">!== &quot;n&quot;</code> fails open. Same line count, opposite blast radius.</li>
<li>Test the interactive path in a pseudo-terminal. Piping only exercises the non-TTY code path, and your users are on the other one.</li>
</ul>
<h2 id="the-symptom-a-at-the-end-of-a-terminal-paste">The Symptom: a % at the End of a Terminal Paste</h2>
<p>Someone sent me a session transcript from sarvam-cli. The last four lines:</p>
<pre><code class="" data-line="">❯ /model
Current model: sarvam-105b
Available: sarvam-105b
model&gt; %
</code></pre>
<p>That trailing <code class="" data-line="">%</code> is zsh telling you the previous command produced output with no final newline. Specifically, it only appears when zsh has <em>regained control</em> — which means the process exited. While sitting at a prompt. Having printed <code class="" data-line="">model&gt;</code> and then simply stopped existing.</p>
<p>No error. No traceback. Nothing in the logs.</p>
<h2 id="why-my-first-diagnosis-was-wrong">Why My First Diagnosis Was Wrong</h2>
<p>My first theory was wrong, and it is worth saying so. I assumed stdin contention. The code attached a raw <code class="" data-line="">process.stdin.on(&quot;data&quot;)</code> listener for a Ctrl+O keybinding <em>while</em> a <code class="" data-line="">readline</code> interface was consuming the same stream. Two readers, one pipe — a classic. I wrote it up confidently.</p>
<p>Then I reproduced it before fixing it, and the theory collapsed. The <code class="" data-line="">/model</code> flow completed perfectly. Additionally, the line buffer survived a mid-line keypress intact. Whatever killed the process, it was not stdin contention.</p>
<h2 id="what-actually-causes-the-nodejs-readline-silent-exit">What Actually Causes the Node.js readline Silent Exit</h2>
<p>Here is the code every Node CLI writes to get an async prompt:</p>
<pre><code class="" data-line="">const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) =&gt; new Promise((resolve) =&gt; rl.question(q, (a) =&gt; resolve(a)));

const name = await ask(&quot;Your name: &quot;);
</code></pre>
<p><code class="" data-line="">rl.question()</code> registers a <strong>one-shot callback</strong> for the next line of input. That is the whole contract. Consequently, there is exactly one way for it to never be called: the interface closes before a line arrives.</p>
<p>Three ordinary things close it:</p>
<ul>
<li><strong>Ctrl+D</strong> — EOF at the terminal</li>
<li><strong>Ctrl+C</strong> — readline&#8217;s default SIGINT behaviour closes the interface</li>
<li><strong>Piped stdin running out of lines</strong> — <code class="" data-line="">printf &#039;a\nb\n&#039; | sarvam</code> when the CLI asks five questions</li>
</ul>
<p>When any of those happen, the callback is dropped. The promise attached to it stays pending. <code class="" data-line="">await</code> never returns.</p>
<p>Now the part that turns a bug into a <em>silent</em> bug. A pending promise is not a running task. Node does not wait on it, because nothing is scheduled — no timer, no socket, no handle. The event loop finds itself with nothing to do, and does the correct thing:</p>
<p><strong>It exits. Status 0.</strong></p>
<p>From the outside, your program reported success. From the inside, it never finished the line it was on.</p>
<pre><code class="" data-line="">$ printf &#039;\n\n\n&#039; | sarvam --init
  sarvam init
  ----------------

Provider [a/b] (default: a): API key: $ echo $?
0
</code></pre>
<p>Success. Nothing written.</p>
<h2 id="why-exit-code-0-is-the-real-damage">Why Exit Code 0 Is the Real Damage</h2>
<p>A crash is loud. You get a stack trace, a non-zero status, and CI goes red. This is the opposite. It is a <strong>false success</strong>, and false success is the failure mode that survives longest in production, because nothing is watching for it.</p>
<p>That gap between &#8220;the process succeeded&#8221; and &#8220;the product worked&#8221; is exactly the <a href="https://linuxcent.com/product-quality-vs-code-quality/">product quality vs code quality</a> split — no test suite in this repo was wrong, and every one of them passed.</p>
<p>Concretely, in my case:</p>
<pre><code class="" data-line="">sarvam --init &amp;&amp; echo &quot;configured&quot; &amp;&amp; deploy.sh
</code></pre>
<p><code class="" data-line="">--init</code> exits 0 having written no config file. However, the <code class="" data-line="">&amp;&amp;</code> chain proceeds anyway. Then <code class="" data-line="">deploy.sh</code> runs against a machine that was never configured. The one signal a shell script has for &#8220;did this work&#8221; was lying.</p>
<h2 id="three-call-sites-one-copy-pasted-habit">Three Call Sites, One Copy-Pasted Habit</h2>
<p>Once I understood the mechanism, I grepped for the shape rather than the symptom — <code class="" data-line="">rl.question</code> wrapped in a <code class="" data-line="">new Promise</code>. It appeared three times.</p>
<p><strong>1. The main REPL prompt.</strong> Ctrl+D exited silently. In practice, this is the one everyone would eventually notice and shrug at, because &#8220;Ctrl+D quits&#8221; looks like intended behaviour when the process disappears.</p>
<p><strong>2. A nested sub-prompt.</strong> The one in the transcript. Identical cause, more visible, because it left a half-drawn prompt on screen.</p>
<p><strong>3. The <code class="" data-line="">--init</code> configuration wizard.</strong> The worst of the three, and the one nobody would have found by hand. Specifically, it only misbehaves under piped stdin — which is exactly how CI and setup scripts drive it.</p>
<p>Same eight lines, copy-pasted, three times. That is the honest reason it appeared three times: not three bugs, one habit.</p>
<h2 id="the-fix-resolve-to-a-sentinel-when-the-interface-closes">The Fix: Resolve to a Sentinel When the Interface Closes</h2>
<p>Resolve to <code class="" data-line="">null</code> when the interface closes, so every caller gets a value instead of hanging:</p>
<pre><code class="" data-line="">let closed = false;
rl.on(&quot;close&quot;, () =&gt; {
  closed = true;
});

const ask = (q: string): Promise&lt;string | null&gt; =&gt;
  new Promise((resolve) =&gt; {
    if (closed) return resolve(null);

    let answered = false;
    const onClose = () =&gt; {
      if (!answered) {
        answered = true;
        resolve(null);
      }
    };

    rl.once(&quot;close&quot;, onClose);
    rl.question(q, (a) =&gt; {
      answered = true;
      rl.removeListener(&quot;close&quot;, onClose);
      resolve(a);
    });
  });
</code></pre>
<p>Three details matter more than they look:</p>
<ul>
<li><strong><code class="" data-line="">resolve(null)</code>, not <code class="" data-line="">reject()</code>.</strong> End of input is not exceptional. It is the normal way a pipe finishes and the normal way a user quits. Rejecting forces every call site into a <code class="" data-line="">try/catch</code> and tempts people to swallow it.</li>
<li><strong>The <code class="" data-line="">answered</code> guard.</strong> Without it, a close event firing after a legitimate answer double-resolves. That is harmless with promises, but it hides ordering bugs.</li>
<li><strong><code class="" data-line="">removeListener</code> on the success path.</strong> A long REPL session asks hundreds of questions. Without this you accumulate a close listener per prompt, and Node starts warning you about a leak around 11.</li>
</ul>
<h2 id="what-null-should-mean-at-each-call-site">What null Should Mean at Each Call Site</h2>
<p>This is where a mechanical fix becomes a design decision. <code class="" data-line="">null</code> means &#8220;input ended.&#8221; What you should <em>do</em> about that differs at every call site. Furthermore, getting it wrong at one of them is a security bug.</p>
<p><strong>At the main prompt — exit cleanly.</strong></p>
<pre><code class="" data-line="">const input = await ask(&quot;❯ &quot;);
if (input === null) break;   // fall through to the normal shutdown path
</code></pre>
<p><strong>At an approval prompt — decline. Always.</strong></p>
<pre><code class="" data-line="">const ans = await ask(`▸ ${tool}: ${summary} [y/N] `);
if (ans === null) return false;  // stdin closed — never assume consent
return ans.toLowerCase().trim() === &quot;y&quot;;
</code></pre>
<p>This is the one that matters. sarvam-cli is an agentic assistant that can run shell commands and write files, gated behind a <code class="" data-line="">[y/N]</code> prompt. Therefore, &#8220;input ended&#8221; must never be read as approval.</p>
<p>Note how easily it goes the other way. Had the original been written as a denial check:</p>
<pre><code class="" data-line="">return ans.toLowerCase().trim() !== &quot;n&quot;;   // looks equivalent. isn&#039;t.
</code></pre>
<p>…then an empty or absent answer becomes <strong>approval</strong>, and a closed stdin auto-approves every pending action. Same number of lines, opposite blast radius. Fail-closed is not a philosophy here. It is a comparison operator — and it is the most concrete example of <a href="https://linuxcent.com/cybersecurity-architecture-principles/">cybersecurity architecture principles</a> I have shipped in one line of code.</p>
<h2 id="why-a-partial-write-is-worse-than-no-write">Why a Partial Write Is Worse Than No Write</h2>
<p><strong>In the config wizard — abort without writing.</strong></p>
<pre><code class="" data-line="">const answers: string[] = [];
for (const q of questions) {
  const a = await ask(q);
  if (a === null) {
    rl.close();
    console.error(&quot;\n  init aborted — input ended before every question was answered.&quot;);
    console.error(`  Nothing written to ${CONFIG_PATH}.\n`);
    return null;   // caller exits non-zero
  }
  answers.push(a);
}
</code></pre>
<p>The tempting alternative is to save whatever you collected. Don&#8217;t. In this codebase a partial config with an empty <code class="" data-line="">apiKey</code> field is <em>worse than no file at all</em>, because of a second bug it interacts with:</p>
<pre><code class="" data-line="">// The config file wins over the environment...
const apiKey = file.apiKey ?? process.env.API_KEY ?? &quot;&quot;;
</code></pre>
<p><code class="" data-line="">??</code> only falls through on <code class="" data-line="">null</code>/<code class="" data-line="">undefined</code>. An empty string is a defined value. As a result, a config file containing <code class="" data-line="">&quot;apiKey&quot;: &quot;&quot;</code> <strong>permanently shadows the environment variable</strong>. Export <code class="" data-line="">API_KEY</code> all you like — the CLI reports &#8220;no API key found&#8221; forever, and the file causing it looks empty and harmless.</p>
<p>A partial write turns a clean failure into a persistent one. No write beats a bad write.</p>
<p>On the caller side, actually surface it:</p>
<pre><code class="" data-line="">if (args.init) {
  const created = await initConfigInteractive();
  process.exit(created ? 0 : 1);
}
</code></pre>
<h2 id="proving-it-testing-the-interactive-path-in-a-pty">Proving It: Testing the Interactive Path in a pty</h2>
<p>You cannot test this properly with a pipe. <code class="" data-line="">readline</code> behaves differently when stdin is not a TTY, so piping only exercises one of the two paths — and the interactive path is the one your users are on.</p>
<p>The one-liner smoke test is what I would add to CI first:</p>
<pre><code class="" data-line="">$ printf &#039;\n\n\n&#039; | sarvam --init; echo &quot;exit=$?&quot;
  init aborted — input ended before every question was answered.
  Nothing written to /home/vamshi/.sarvam/config.json.
exit=1          # ← was 0 before the fix. Non-zero is the whole point.
</code></pre>
<p>For the interactive path, drive a pseudo-terminal. Additionally, this needs no new dependencies — it is Python&#8217;s stdlib:</p>
<pre><code class="" data-line="">#!/usr/bin/env python3
&quot;&quot;&quot;Send Ctrl+D at a prompt and assert the CLI exits like it means it.&quot;&quot;&quot;
import os, pty, subprocess, time

master, slave = pty.openpty()
p = subprocess.Popen([&quot;sarvam&quot;], stdin=slave, stdout=slave, stderr=slave, close_fds=True)
os.close(slave)

time.sleep(1.0)            # let it draw its prompt
os.write(master, b&quot;\x04&quot;)  # Ctrl+D  (use b&quot;\x03&quot; for Ctrl+C)
time.sleep(1.0)

print(&quot;exit:&quot;, p.wait(timeout=5))
</code></pre>
<p>Running it against the fixed build:</p>
<pre><code class="" data-line="">$ python3 test_eof.py
exit: 0         # ← clean shutdown, farewell line printed
                #   before the fix this also printed 0 — but with no farewell
</code></pre>
<p>That distinction is the whole test. Together, the exit code and the farewell line tell you which of the two happened. This is what <a href="https://linuxcent.com/continuous-security-validation/">continuous security validation</a> looks like at CLI scale: assert the observable behaviour, not just the status.</p>
<h2 id="quick-reference">Quick Reference</h2>
<p>Expected behaviour after the fix:</p>
<table>
<thead>
<tr>
<th>Input</th>
<th>Exit code</th>
<th>Behaviour</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">exit</code> / <code class="" data-line="">quit</code></td>
<td>0</td>
<td>Normal shutdown</td>
</tr>
<tr>
<td><strong>Ctrl+D</strong> (EOF)</td>
<td>0</td>
<td>Clean shutdown, farewell printed</td>
</tr>
<tr>
<td><strong>Ctrl+C</strong> (SIGINT)</td>
<td><strong>130</strong></td>
<td>Clean shutdown, distinguishable from success</td>
</tr>
<tr>
<td>Piped stdin, too few lines</td>
<td><strong>1</strong></td>
<td>Aborts, writes nothing</td>
</tr>
<tr>
<td>Approval prompt, stdin closed</td>
<td>—</td>
<td>Returns <code class="" data-line="">false</code> — action declined</td>
</tr>
</tbody>
</table>
<p>Use 130 for SIGINT. That is the <code class="" data-line="">128 + signal</code> convention, and the only way a wrapping script can tell &#8220;the user interrupted this&#8221; from &#8220;this finished.&#8221; However, it needs an explicit handler, because readline&#8217;s default is a silent close:</p>
<pre><code class="" data-line="">let interrupted = false;
rl.on(&quot;SIGINT&quot;, () =&gt; {
  interrupted = true;
  rl.close();
});
// …after the loop:
if (interrupted) process.exitCode = 130;
</code></pre>
<h2 id="beyond-node-any-callback-with-a-path-that-never-runs">Beyond Node: Any Callback With a Path That Never Runs</h2>
<p>The specific API is Node&#8217;s. However, the pattern is everywhere: <strong>an async primitive whose completion callback has a path that never runs.</strong></p>
<p>Any time you wrap a callback API in a promise, ask the same question — <em>what are all the ways this callback might not be called?</em> Closed streams, cancelled requests, timed-out sockets, aborted signals. In every one of those cases, a bare <code class="" data-line="">new Promise(resolve =&gt; api(cb))</code> becomes a permanent hang. Moreover, in an event-loop runtime, a permanent hang looks exactly like a clean exit.</p>
<p>The tell is a process that ends <strong>without printing whatever it normally prints on the way out</strong>. If your CLI has a farewell line, a summary, or a flush, its absence is your signal — not the exit code, which is lying.</p>
<h2 id="cissp-domain-mapping">CISSP Domain Mapping</h2>
<table>
<thead>
<tr>
<th>Domain</th>
<th>Name</th>
<th>Relevance</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>3</strong></td>
<td>Security Architecture and Engineering</td>
<td>Secure defaults and fail-closed design. When the system loses the ability to obtain consent, it must assume consent was refused. Note how narrowly it was avoided: <code class="" data-line="">=== &quot;y&quot;</code> fails closed, <code class="" data-line="">!== &quot;n&quot;</code> fails open, and code review rarely catches the difference.</td>
</tr>
<tr>
<td><strong>8</strong></td>
<td>Software Development Security</td>
<td>Error handling at trust boundaries. Silent failure is the anti-pattern — a system that cannot distinguish &#8220;succeeded&#8221; from &#8220;never ran&#8221; cannot be reasoned about, and every consumer downstream inherits the ambiguity.</td>
</tr>
<tr>
<td><strong>7</strong></td>
<td>Security Operations</td>
<td>Exit codes are an operational interface. <code class="" data-line="">0</code> means a shell <code class="" data-line="">&amp;&amp;</code> chain proceeds. Returning 0 from a function that did nothing is, in automation terms, a false negative on an integrity check.</td>
</tr>
</tbody>
</table>
<h2 id="key-takeaways">Key Takeaways</h2>
<ol>
<li><strong><code class="" data-line="">rl.question()</code> never fires if the interface closes first.</strong> Ctrl+D, Ctrl+C, and exhausted piped stdin all close it.</li>
<li><strong>A pending promise is not a running task.</strong> Node exits cleanly when the loop empties, so the failure presents as success.</li>
<li><strong>Resolve to a sentinel, don&#8217;t reject.</strong> End of input is normal, not exceptional.</li>
<li><strong>Decide what &#8220;input ended&#8221; means per call site.</strong> Exit at a prompt, <em>decline</em> at a consent gate, abort at a wizard.</li>
<li><strong>Never assume consent from absent input.</strong> Write <code class="" data-line="">=== &quot;y&quot;</code>, never <code class="" data-line="">!== &quot;n&quot;</code>.</li>
<li><strong>A partial write can be worse than no write</strong> — especially where an empty string is a meaningful, shadowing value.</li>
<li><strong>Test the interactive path in a pty.</strong> A pipe tests the other code path entirely.</li>
<li><strong>Reproduce before you fix.</strong> My confident first diagnosis was wrong, and only a reproduction attempt caught it before it became a wasted refactor.</li>
</ol>
<h2 id="try-sarvam-cli">Try sarvam-cli</h2>
<p>The CLI in this post is <a href="https://github.com/indic-ai-contribs/sarvam-cli">sarvam-cli</a> — an MIT-licensed, open-source agentic coding assistant powered by Sarvam AI. It reads, writes, and edits files and runs shell commands in your project, with your approval before any side effect. That approval gate is exactly the one discussed above, which is why the fail-closed behaviour mattered enough to write up.</p>
<pre><code class="" data-line="">git clone https://github.com/indic-ai-contribs/sarvam-cli.git
cd sarvam-cli
npm install
npm run build
npm link

sarvam --init     # exits non-zero now if you don&#039;t finish the wizard
</code></pre>
<p>The fixes described here shipped in v0.2.9 and v0.2.10. Issues and pull requests are welcome — particularly from anyone who has fought the same class of bug in their own CLI. If the project is useful to you, a star on <a href="https://github.com/indic-ai-contribs/sarvam-cli">the sarvam-cli GitHub repo</a> genuinely helps it reach more Indian-language AI developers.</p>
<p>Get the next deep-dive in your inbox when it publishes → <a href="https://linuxcent.com/#subscribe">subscribe to linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&#038;title=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" data-a2a-url="https://linuxcent.com/nodejs-readline-silent-exit/" data-a2a-title="Exit Code 0 Lied: The Silent Node.js Bug That Hit sarvam-cli Three Times"></a></p><p>The post <a href="https://linuxcent.com/nodejs-readline-silent-exit/">Exit Code 0 Lied: The Silent Node.js Bug That Hit sarvam-cli Three Times</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/nodejs-readline-silent-exit/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2295</post-id>	</item>
		<item>
		<title>Product Quality vs Code Quality: Why Your Green CI Still Loses Users</title>
		<link>https://linuxcent.com/product-quality-vs-code-quality/</link>
					<comments>https://linuxcent.com/product-quality-vs-code-quality/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 20 Jul 2026 04:15:40 +0000</pubDate>
				<category><![CDATA[Product Quality]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[Invigil]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[Software Quality]]></category>
		<guid isPermaLink="false">https://linuxcent.com/product-quality-vs-code-quality/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Product quality vs code quality: why green CI still loses users, and how a repo-grading gate catches the gaps linters never see. Try it in 2 minutes.</p>
<p>The post <a href="https://linuxcent.com/product-quality-vs-code-quality/">Product Quality vs Code Quality: Why Your Green CI Still Loses Users</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>9 min read</em></p>
<p><strong>EP01: Product Quality vs Code Quality</strong> · <a href="/the-legible-repo/">All The Legible Repo Episodes →</a></p>
<p>This series is about the quality layer your CI can&#8217;t see. Each episode takes one failure that linters, scanners, and test suites never catch, shows the incident that proves it, and ends with one command you can run today. This opener names the problem — and introduces the gate that measures it.</p>
<h2 id="table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#tldr">TL;DR</a></li>
<li><a href="#quick-check-what-grade-is-your-repo-right-now">Quick Check: What Grade Is Your Repo Right Now</a></li>
<li><a href="#the-500-nobody-reported">The 500 Nobody Reported</a></li>
<li><a href="#where-product-quality-sits-and-why-linters-cant-see-it">Where Product Quality Sits (and Why Linters Can&#8217;t See It)</a></li>
<li><a href="#the-questions-that-decide-whether-a-stranger-stays">The Questions That Decide Whether a Stranger Stays</a></li>
<li><a href="#how-the-gate-works-scorecard-plus-cold-start">How the Gate Works: Scorecard Plus Cold-Start</a></li>
<li><a href="#what-this-means-for-your-repos-right-now">What This Means for Your Repos Right Now</a></li>
<li><a href="#-production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</a></li>
<li><a href="#quick-reference">Quick Reference</a></li>
<li><a href="#framework-alignment">Framework Alignment</a></li>
<li><a href="#key-takeaways">Key Takeaways</a></li>
<li><a href="#whats-next">What&#8217;s Next</a></li>
</ul>
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Product quality vs code quality is the gap between &#8220;the tests pass&#8221; and &#8220;a stranger can actually use this&#8221; — and no linter measures it.</li>
<li>The costliest failures are silent: the person who hits friction in the first ten minutes never files an issue. They close the tab.</li>
<li>Legibility is checkable: README length, copy-paste quickstart, <code class="" data-line="">.env.example</code>, actionable errors, a published artifact that still installs today.</li>
<li>Invigil (Apache-2.0) mechanizes ~35 of these checks into gate levels G1–G7 with a letter grade — and prints the exact fix for every failure.</li>
<li>It grades itself in CI: a pull request that lowers Invigil&#8217;s own score doesn&#8217;t merge.</li>
</ul>
<h2 id="quick-check-what-grade-is-your-repo-right-now">Quick Check: What Grade Is Your Repo Right Now</h2>
<p>Before the story, the evidence. Two commands, two minutes, on any repo you maintain:</p>
<pre><code class="" data-line="">pip install invigil
invigil score . --offline
</code></pre>
<p>Sample output, annotated:</p>
<pre><code class="" data-line="">Invigil — myproject
Gate G2 · Grade C+ · 19/27 (70%)          ← gate = maturity rung, grade = weighted score

FAIL [G1] README is a landing page (≤300 lines)     (effort: minutes)
      fix: move deep-dive sections to docs/; keep quickstart + pitch
FAIL [G1] .env.example documents every config var   (effort: minutes)
      fix: create .env.example listing each var with purpose + default
FAIL [G2] Errors carry a correlation ID             (effort: hours)
      fix: add a global exception handler returning {&quot;error_id&quot;: ...}
</code></pre>
<p>Every failing line names the check, the effort class, and the exact fix. However you feel about the individual opinions, notice what just happened: nothing in your existing CI produces this view.</p>
<h2 id="the-500-nobody-reported">The 500 Nobody Reported</h2>
<p>The day I renamed a package and pushed the new wheel, every UI page it served returned a 500. The commit message said &#8220;verified.&#8221; I had verified the <em>import</em> — not the <em>experience</em>. No test caught it, because the tests ran against my source tree, not against the artifact a stranger downloads. And no user caught it for me. The first stranger who hit that 500 did what strangers do: closed the tab and never came back.</p>
<p>That is the failure mode that should keep maintainers up at night. <strong>Absence of complaints is not absence of problems.</strong> Silence is the loudest negative signal a project gets.</p>
<p>A clean-virtualenv install from an empty directory found the bug in minutes. That habit — being your own first angry user — became a doctrine. Later, the doctrine became a CI gate called <strong>Invigil</strong>, because habits don&#8217;t run nightly and machines do.</p>
<h2 id="where-product-quality-sits-and-why-linters-cant-see-it">Where Product Quality Sits (and Why Linters Can&#8217;t See It)</h2>
<pre><code class="" data-line="">                    ┌─────────────────────────────────────────┐
                    │        WHAT YOUR CI CHECKS TODAY        │
                    │  ruff / eslint      → code style        │
                    │  pytest / jest      → source behavior   │
                    │  Trivy / Dependabot → CVEs, deps        │
                    │  Scorecard          → supply chain      │
                    └────────────────┬────────────────────────┘
                                     │  all green &#x2705;
                                     ▼
                    ┌─────────────────────────────────────────┐
                    │        WHAT THE STRANGER MEETS          │
                    │  README (landing page or wall of text?) │
                    │  Quickstart (works from empty dir?)     │
                    │  Published artifact (installs TODAY?)   │
                    │  First error (fix included or trace?)   │
                    │  llms.txt / AGENTS.md (agent-readable?) │
                    └─────────────────────────────────────────┘
                          nothing above checks this layer
</code></pre>
<p>The product quality vs code quality distinction is exactly this diagram. As a result, a repo can be immaculate in the top box and unusable in the bottom one — green CI, linted code, zero CVEs, and a quickstart that fails on the first copy-paste. In contrast to code quality, product quality has no reflexive tooling. Every good maintainer checks these things by hand, occasionally, when they remember. Nobody&#8217;s CI does it on every pull request.</p>
<p>I build hardened infrastructure for a living, and the same lesson repeats there: a standard that isn&#8217;t enforced mechanically is a wish. That&#8217;s why <a href="/linux-hardening-as-code/">Linux hardening as code</a> beats hardening runbooks — and it&#8217;s why legibility needs a gate, not a checklist.</p>
<h2 id="the-questions-that-decide-whether-a-stranger-stays">The Questions That Decide Whether a Stranger Stays</h2>
<p>Specifically, the gate asks the questions your CI never asks:</p>
<ul>
<li>Can someone get from &#8220;found the repo&#8221; to &#8220;it worked on my machine&#8221; in ten minutes?</li>
<li>When something fails, does the error include the fix — or a traceback?</li>
<li>Is the README a landing page, or 600 lines of accumulated documentation?</li>
<li>Does the artifact you published <em>still install today</em>, after your dependencies drifted?</li>
<li>Is there an <code class="" data-line="">.env.example</code>, or do users reverse-engineer your config from source?</li>
<li>Can an AI agent — now often the first reader — parse your <code class="" data-line="">llms.txt</code> and <code class="" data-line="">AGENTS.md</code> without hitting stale paths or a leaked key?</li>
</ul>
<p>Each question maps to a mechanical check. Together, ~35 checks roll up into gate levels <strong>G1–G7</strong> — a maturity ladder, not a binary pass/fail — plus a weighted letter grade. A repo reaches gate G<em>n</em> only when every mandatory check at or below <em>n</em> passes.</p>
<h2 id="how-the-gate-works-scorecard-plus-cold-start">How the Gate Works: Scorecard Plus Cold-Start</h2>
<h3 id="layer-1-the-scorecard-every-pr-seconds">Layer 1 — the scorecard (every PR, seconds)</h3>
<p>The static layer inspects the repo and its metadata: LICENSE, README length, quickstart shape, tracked secrets, SHA-pinned actions, enforced lockfile, coverage floor, docs index, <code class="" data-line="">llms.txt</code>/<code class="" data-line="">AGENTS.md</code> hygiene, and more. It runs offline in a pre-commit hook in roughly 120 ms, because a gate that adds friction is a gate that gets uninstalled.</p>
<pre><code class="" data-line="">invigil score . --format markdown   # PR-comment-ready table
</code></pre>
<h3 id="layer-2-the-cold-start-gate-nightly">Layer 2 — the cold-start gate (nightly)</h3>
<p>This is the layer that would have caught my 500. Instead of testing the source tree, it boots the <em>published</em> artifact — the wheel on PyPI, the image on GHCR — on a clean runner and probes its surface within a ten-minute budget:</p>
<pre><code class="" data-line=""># .invigil.yml
artifacts:
  - { type: pypi, name: &quot;myapp[all]&quot; }
  - { type: ghcr, image: ghcr.io/me/myapp:latest, port: 8000 }
probes:
  - { url: &quot;/&quot;, expect_status: 200 }
</code></pre>
<p>Because it installs from the real registry into a real empty environment, it catches the class of bug where CI passes but the shipped thing is broken: the missing template directory, the config default pointing at localhost, the dependency that resolved differently after an upstream release.</p>
<h2 id="what-this-means-for-your-repos-right-now">What This Means for Your Repos Right Now</h2>
<p>Start in report-only mode. The <code class="" data-line="">progressive</code> profile scores everything and gates nothing — you get the visibility without a wall of red blocking your next merge. Flip to <code class="" data-line="">enforce</code> once the grade stabilizes, the same way you&#8217;d introduce any merge check.</p>
<p>The doctrine is opinionated, and that&#8217;s deliberate — but the gate bends instead of breaking. Profiles (<code class="" data-line="">strict | progressive | light</code>), per-check <code class="" data-line="">weights</code>, and <code class="" data-line="">optional</code> flags let a team disagree with a specific opinion without forking the tool. Additionally, network-dependent checks that time out become SKIPs excluded from the grade — never a false downgrade that erodes trust in the number.</p>
<p>One more thing, because trust matters for a tool that grades others: <strong>Invigil grades itself in CI.</strong> A pull request that lowers its own score won&#8217;t merge. The gate passes its own gate — currently G5, grade A+.</p>
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Enforcing on day one.</strong> Turning on <code class="" data-line="">enforce: true</code> before the team has seen the report produces a wall of failures and an uninstall. What breaks: adoption. How to detect it: grumbling in your PR comments. The fix: <code class="" data-line="">progressive</code> first, enforce after two weeks of stable grades.</p>
<p><strong>Treating the grade as the goal.</strong> The grade is a proxy for a stranger&#8217;s first ten minutes. Gaming it (a hollow <code class="" data-line="">.env.example</code>, a README split that hides the quickstart) passes the check and still loses the user. The fix is cultural, not mechanical — review the fix, not just the score delta.</p>
<p><strong>Skipping the cold-start layer because &#8220;CI already tests installs.&#8221;</strong> CI installs from the source tree with your lockfile present. The stranger installs from the registry into nothing. These diverge silently after any packaging change — that divergence is invisible until you test the published artifact itself.</p>
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">invigil score .</code></td>
<td>Full scorecard: gate, grade, exact fix per failure</td>
</tr>
<tr>
<td><code class="" data-line="">invigil score . --offline</code></td>
<td>Fast local checks only (~120 ms class)</td>
</tr>
<tr>
<td><code class="" data-line="">invigil score . --format markdown</code></td>
<td>PR-comment / job-summary table</td>
</tr>
<tr>
<td><code class="" data-line="">invigil evaluate .</code></td>
<td>Alias of <code class="" data-line="">score</code> — the verb agents reach for</td>
</tr>
<tr>
<td><code class="" data-line="">invigil portfolio p1 p2 --update FILE.md</code></td>
<td>Grade many repos, update a tracked table</td>
</tr>
<tr>
<td>GitHub Action</td>
<td><code class="" data-line="">uses: invigil/invigil@v1</code> — report-only by default</td>
</tr>
</tbody>
</table>
<h2 id="framework-alignment">Framework Alignment</h2>
<table>
<thead>
<tr>
<th>CISSP Domain</th>
<th>Relevance</th>
</tr>
</thead>
<tbody>
<tr>
<td>Domain 8 — Software Development Security</td>
<td>The gate enforces secure-SDLC hygiene (no tracked secrets, least-privilege config via <code class="" data-line="">.env.example</code>, SHA-pinned actions, enforced lockfile) as a merge condition rather than a wiki page.</td>
</tr>
<tr>
<td>Domain 7 — Security Operations</td>
<td>Signed releases, SBOM, and the nightly published-artifact check operationalize artifact integrity — continuous evidence instead of a pre-audit scramble.</td>
</tr>
<tr>
<td>Domain 1 — Security &amp; Risk Management</td>
<td>Profiles and weighted gates turn a subjective quality bar into a measurable control with a defined threshold — governance expressed in code.</td>
</tr>
</tbody>
</table>
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Product quality and code quality are different layers; your CI only watches one of them.</li>
<li>Test the experience, not the import — the developer&#8217;s machine is a lie, and so is the source tree.</li>
<li>Silence is data: the users you never hear from are the ones who hit the friction.</li>
<li>A quality bar you can&#8217;t measure is an opinion; a gate you bypass is dead weight — make it fast, bendable, and report-only by default.</li>
<li>Trust tools that hold themselves to their own standard: Invigil&#8217;s own PRs merge only if its self-grade holds.</li>
<li>Defaults are never neutral — the same reason <a href="/cloud-ami-security-risks-custom-os-images/">cloud AMI security risks</a> demand custom images applies to your repo&#8217;s out-of-the-box experience.</li>
</ul>
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP02 goes deep on the layer that caught my 500: testing the <em>published</em> artifact, not the source tree. Clean-runner boots, real-registry installs, probe budgets — and why &#8220;it works in CI&#8221; is a statement about your lockfile, not your users. <strong>EP02: How to Test Your Published PyPI Package — Before a Stranger Does.</strong></p>
<blockquote>
<p><strong>Invigil is Apache-2.0 and built in the open.</strong> If this episode named a failure you&#8217;ve shipped (we all have), there are more checks waiting to be written — good-first-issues with acceptance criteria at <strong><a href="https://github.com/invigil/invigil">github.com/invigil/invigil</a></strong>. Pick one, or open a Discussion and say hello. First-time contributors get fast reviews and release-notes credit.</p>
</blockquote>
<p>Get EP02 in your inbox when it publishes → <a href="/subscribe/">subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&#038;title=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" data-a2a-url="https://linuxcent.com/product-quality-vs-code-quality/" data-a2a-title="Product Quality vs Code Quality: Why Your Green CI Still Loses Users"></a></p><p>The post <a href="https://linuxcent.com/product-quality-vs-code-quality/">Product Quality vs Code Quality: Why Your Green CI Still Loses Users</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/product-quality-vs-code-quality/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2272</post-id>	</item>
		<item>
		<title>BakeX — OS Hardening as a Platform</title>
		<link>https://linuxcent.com/stratum-os-hardening-platform/</link>
					<comments>https://linuxcent.com/stratum-os-hardening-platform/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sun, 31 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[OS Image Builder]]></category>
		<category><![CDATA[BakeX]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[Infrastructure as Code]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[OS Hardening]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1834</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>Stratum — open-core (Apache 2.0) OS hardening platform: declare baselines in YAML, build across six clouds, and gate CI/CD deployments on compliance grade.</p>
<p>The post <a href="https://linuxcent.com/stratum-os-hardening-platform/">BakeX — OS Hardening as a Platform</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>OS Hardening as Code, Episode 6</em><br />
<em><a href="https://linuxcent.com/cloud-ami-security-risks-custom-os-images/">Cloud AMI Security Risks</a> · <a href="/linux-hardening-as-code/">Linux Hardening as Code</a> · <a href="/linux-hardening-multi-cloud/">Multi-Cloud OS Hardening</a> · <a href="/automated-compliance-scanning-openscap/">Automated OpenSCAP Compliance</a> · <a href="/hardened-image-cicd-pipeline-gate/">CI/CD Compliance Gate</a> · </em><em>BakeX Platform</em>**</p>
<blockquote>
<p><strong>Note:</strong> this series was written when the project was called <strong>Stratum</strong>. It was renamed to<br />
<strong>BakeX</strong> at v0.6.0 — same project, same Apache 2.0 license, same team. The old<br />
<code class="" data-line="">github.com/rrskris/Stratum</code> URL redirects here, and <code class="" data-line="">pip install stratumoss</code> is retired in<br />
favour of <code class="" data-line="">pip install bakex</code>. Current home:<br />
<a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a>.</p>
</blockquote>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>BakeX is open-source under Apache 2.0 — the engine, blueprint format, scanner, and Pipeline API are all in the repository</li>
<li>Self-hostable end to end: nothing is locked to a hosted service, and there is no paid tier gating the pipeline</li>
<li>Two real extension points: <strong>provider plugins</strong> (drop-in <code class="" data-line="">.py</code> or a <code class="" data-line="">bakex.providers</code> entry point) and <strong>blueprints</strong> (pure YAML, no code)</li>
<li>Architecture: Blueprint YAML → Engine → Provider Layer → Ansible-Lockdown → OpenSCAP → Golden Image → Pipeline API</li>
<li>The series taught the user-facing interface for five episodes; EP06 covers what&#8217;s underneath and how to build on it</li>
<li>Installation is <code class="" data-line="">git clone</code> + <code class="" data-line="">docker compose up</code>, or <code class="" data-line="">pip install bakex</code> for the CLI and web app</li>
</ul>
<hr />
<h2 id="the-series-arc-inverted">The Series Arc, Inverted</h2>
<p>EP01 showed that default cloud AMIs arrive pre-broken. By the time you reach EP06, that problem has a complete solution:</p>
<pre><code class="" data-line="">EP01 — The problem:
  Default AMI → Production → Security audit finds gaps
  (unknown OS baseline, unverified hardening, no evidence)

EP06 — The solution:
  HardeningBlueprint YAML
           ↓
    bakex validate          ← EP02 (blueprint as code)
    bakex build             ← EP02
      one file per provider ← EP03 (multi-cloud)
           ↓
    OpenSCAP scan           ← EP04 (compliance grading)
    Grade: A (score 94)
           ↓
    POST /api/pipeline/scan ← EP05 (CI/CD gate)
    passed: true
           ↓
    Production deployment
    (Grade A, SARIF attached, blueprint version-controlled)
</code></pre>
<p>For five episodes, you&#8217;ve used BakeX as a user. This episode covers what it looks like to run it yourself, extend it, and build on it.</p>
<hr />
<p>I&#8217;ve spent years watching infrastructure teams solve the same OS hardening problem in slightly different ways. Custom scripts that drift. OpenSCAP runs that produce evidence no one reads. Compliance checklists completed by humans who have competing priorities.</p>
<p>The tools exist. <code class="" data-line="">ansible-lockdown</code> applies CIS controls reliably. OpenSCAP verifies them accurately. The CI/CD systems can enforce anything you can express as a pass/fail. The gap isn&#8217;t the tooling — it&#8217;s the integration layer that ties them together into a reproducible, auditable pipeline.</p>
<p>BakeX is that integration layer, open-sourced.</p>
<p>The philosophy is the same as Terraform applied to OS security posture: declare the desired state in a version-controlled file, apply it reproducibly, and verify it automatically. The skip-at-2am problem disappears not because engineers are more careful, but because there&#8217;s no step to skip.</p>
<hr />
<h2 id="the-architecture">The Architecture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────┐
│                 HardeningBlueprint YAML                  │
│         (version-controlled, provider-agnostic)          │
└─────────────────────┬───────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────┐
│                    BakeX Engine                          │
│                  (Apache 2.0, OSS)                       │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────┐  │
│  │  Blueprint  │  │   Provider   │  │    Scheduler   │  │
│  │   Parser    │  │    Layer     │  │  (parallel     │  │
│  │             │  │  AWS  GCP    │  │   multi-cloud  │  │
│  │  Validates  │  │  Azure DO    │  │   builds)      │  │
│  │  schema +   │  │  Linode      │  │                │  │
│  │  overrides  │  │  Proxmox     │  │                │  │
│  └─────────────┘  └──────────────┘  └────────────────┘  │
└─────────────────────┬───────────────────────────────────┘
                      │
           ┌──────────┴──────────┐
           ▼                     ▼
  ┌─────────────────┐   ┌─────────────────┐
  │ Ansible-Lockdown │   │  OpenSCAP       │
  │  Runner          │   │  Scanner        │
  │                  │   │                 │
  │  UBUNTU22-CIS    │   │  A-F grade      │
  │  RHEL8-STIG      │   │  SARIF export   │
  │  Custom roles    │   │  Drift detect   │
  └────────┬─────────┘   └────────┬────────┘
           │                      │
           └──────────┬───────────┘
                      │
                      ▼
         ┌─────────────────────────┐
         │   Golden Image          │
         │   (AMI / GCP / Azure)   │
         │   + compliance metadata │
         └────────────┬────────────┘
                      │
                      ▼
         ┌─────────────────────────┐
         │   Pipeline API          │
         │   (Apache 2.0, OSS)     │
         │                         │
         │  POST /api/pipeline/scan │
         │  ← CI/CD gate           │
         └─────────────────────────┘
</code></pre>
<p>Every component is open-source under Apache 2.0. The engine, provider layer, Ansible runner, OpenSCAP scanner, and Pipeline API are all in the repository. Nothing is locked to a hosted service.</p>
<hr />
<h2 id="installation">Installation</h2>
<p>Three ways in, depending on how much you want installed on the host.</p>
<p><strong>Docker Compose — recommended, everything preinstalled:</strong></p>
<pre><code class="" data-line="">git clone https://github.com/invicton/bakex.git
cd bakex
docker compose up
</code></pre>
<p>Open <strong>http://localhost:8001</strong>. Log in with any username and the admin token as the password —<br />
it&#8217;s generated on first start and written to <code class="" data-line="">data/.admin_token</code>. Set <code class="" data-line="">BAKEX_ADMIN_TOKEN</code> and<br />
<code class="" data-line="">BAKEX_SECRET_KEY</code> in <code class="" data-line="">docker-compose.yml</code> if you want logins that survive a rebuild.</p>
<p>Compose mounts <code class="" data-line="">~/.aws</code>, <code class="" data-line="">~/.config/gcloud</code>, and <code class="" data-line="">~/.ssh</code> read-only, plus persistent <code class="" data-line="">./data</code>,<br />
<code class="" data-line="">./profiles</code>, and <code class="" data-line="">./plugins/providers</code>. That last mount is the one to notice — it&#8217;s the<br />
drop-in directory for provider plugins, which matters in the next section.</p>
<p><strong>Published image:</strong></p>
<pre><code class="" data-line="">docker run -p 8000:8000 rrskris/bakex:latest
</code></pre>
<p><strong>PyPI — CLI and web app:</strong></p>
<pre><code class="" data-line="">pip install &quot;bakex[all-providers]&quot;   # or pick extras: aws, gcp, azure, linode, digitalocean, proxmox
bakex serve --port 8000
</code></pre>
<p>One caveat worth stating plainly rather than letting you discover it: the extras install each<br />
provider&#8217;s cloud SDK, and Ansible and OpenSCAP must be present on the host for real builds. If you<br />
want the batteries-included path, use Compose. <code class="" data-line="">bakex validate</code> works anywhere with no host<br />
dependencies at all.</p>
<p>There is no Helm chart. BakeX is a build tool that talks to cloud APIs, not a cluster workload —<br />
it does not need to live in Kubernetes to harden images for it.</p>
<hr />
<h2 id="the-three-extension-points">The Three Extension Points</h2>
<h3 id="1-blueprints-the-extension-point-with-no-code-in-it">1. Blueprints — the extension point with no code in it</h3>
<p>The highest-leverage way to extend BakeX isn&#8217;t Python. It&#8217;s a YAML file.</p>
<p>A blueprint is a complete, self-contained description of a hardened OS on a specific provider,<br />
and the library ships 18 of them. Adding the nineteenth — say Ubuntu 24.04, or CIS Level 2 for a<br />
distro that only has Level 1 today — requires no engine changes, because the benchmark, profile,<br />
and datastream are just strings handed to <code class="" data-line="">oscap</code>.</p>
<p>The full format is published as a JSON Schema (Draft 2020-12) at<br />
<code class="" data-line="">docs/schema/hardening-blueprint.schema.json</code>. Point your editor at it for autocomplete and<br />
inline validation, or hand it to an LLM and let it draft the blueprint — the schema was published<br />
partly so that agents could write these correctly without reading the source.</p>
<p>The loop is short enough to run in a coffee break:</p>
<pre><code class="" data-line="">$EDITOR blueprints/ubuntu/24.04/cis-l1-aws.yaml
bakex validate blueprints/ubuntu/24.04/cis-l1-aws.yaml
</code></pre>
<p>Validation is offline and checks more than syntax — it rejects OS/provider combinations the<br />
catalog doesn&#8217;t support, so you find out that a distro isn&#8217;t available on your target cloud in<br />
milliseconds rather than fifteen minutes into a paid build.</p>
<h3 id="2-provider-plugins">2. Provider Plugins</h3>
<p>Adding a cloud means implementing four methods. That&#8217;s the whole interface<br />
(<code class="" data-line="">bakex/plugins/base_provider.py</code>):</p>
<pre><code class="" data-line=""># plugins/providers/my_cloud.py
from bakex.plugins.base_provider import BaseProvider, ProviderResult
from bakex.core.models import ComplianceProfile

class MyCloudProvider(BaseProvider):
    name = &quot;my-cloud&quot;          # matches target.provider in a blueprint

    def provision(self, profile: ComplianceProfile, **kwargs) -&gt; str:
        &quot;&quot;&quot;Launch a build instance; return its instance ID.&quot;&quot;&quot;
        ...

    def run_ansible(self, instance_id: str, profile: ComplianceProfile) -&gt; None:
        &quot;&quot;&quot;Apply the Ansible-Lockdown hardening roles.&quot;&quot;&quot;
        ...

    def snapshot(self, instance_id: str, profile: ComplianceProfile) -&gt; ProviderResult:
        &quot;&quot;&quot;Capture the golden image; return the artifact ID.&quot;&quot;&quot;
        ...

    def teardown(self, instance_id: str) -&gt; None:
        &quot;&quot;&quot;Destroy the ephemeral build instance.&quot;&quot;&quot;
        ...
</code></pre>
<p>There is no registration command. The loader (<code class="" data-line="">bakex/plugins/loader.py</code>) is hybrid and finds<br />
plugins two ways:</p>
<ol>
<li><strong>Drop-in</strong> — put the <code class="" data-line="">.py</code> file in <code class="" data-line="">plugins/providers/</code>. That directory is a Compose volume<br />
   mount, so a plugin dropped there is live in the container without rebuilding an image.</li>
<li><strong>Entry point</strong> — ship a pip-installable package declaring a <code class="" data-line="">bakex.providers</code> entry point.<br />
   This is how a third party distributes a provider without touching the BakeX repo.</li>
</ol>
<p>Entry points load first and drop-ins load second, so a local file deliberately shadows an<br />
installed package of the same name — which is exactly what you want when debugging someone<br />
else&#8217;s provider.</p>
<p>The plugin becomes usable by writing <code class="" data-line="">provider: my-cloud</code> in a blueprint&#8217;s <code class="" data-line="">target</code> block. There<br />
is no <code class="" data-line="">--provider</code> flag to pass, because there is no <code class="" data-line="">--provider</code> flag anywhere.</p>
<p>One honest note on the validation interaction from EP02: the compatibility check only objects<br />
when <em>both</em> the OS and the provider are in the catalog. An unknown provider is assumed to be a<br />
valid third-party plugin rather than an error — existence is the plugin registry&#8217;s call at build<br />
time, compatibility is validation&#8217;s. That&#8217;s what makes shipping a provider out-of-tree possible<br />
at all.</p>
<h3 id="3-pipeline-integrations">3. Pipeline Integrations</h3>
<p>Beyond the curl-based gate from EP05, BakeX has a webhook system. Webhooks are registered through<br />
the API rather than a config file, so they can be managed by the same automation that manages<br />
everything else:</p>
<pre><code class="" data-line="">curl -X POST http://localhost:8001/api/webhooks \
  -H &quot;Content-Type: application/json&quot; \
  -d &#039;{
    &quot;url&quot;: &quot;https://hooks.slack.com/services/…&quot;,
    &quot;events&quot;: [&quot;build.failed&quot;, &quot;scan.grade_change&quot;],
    &quot;label&quot;: &quot;platform-security alerts&quot;
  }&#039;
</code></pre>
<p>Five events fire: <code class="" data-line="">build.complete</code>, <code class="" data-line="">build.failed</code>, <code class="" data-line="">scan.complete</code>, <code class="" data-line="">scan.failed</code>, and<br />
<code class="" data-line="">scan.grade_change</code>. Registering an unknown event name is a 422 rather than a silent no-op —<br />
a small thing that saves an afternoon.</p>
<p><code class="" data-line="">scan.grade_change</code> is the one to wire up first. A build that fails is loud on its own; a base<br />
image that quietly slid from A to B between two scans is the signal nobody notices.</p>
<p>Deliveries are signed. The registration response returns a secret <strong>once</strong>, and every request<br />
carries an <code class="" data-line="">X-BakeX-Signature: sha256=…</code> HMAC so the receiver can verify the payload came from<br />
your BakeX instance and not from anyone who guessed the endpoint URL.</p>
<p>There&#8217;s a defensive detail here that&#8217;s worth calling out, because it&#8217;s the kind of thing that<br />
usually ships broken: webhook target URLs are checked against loopback, private, link-local, and<br />
reserved ranges — including <code class="" data-line="">169.254.169.254</code> — and they&#8217;re <strong>re-resolved at send time</strong>, not just<br />
at registration. A user-configurable URL that the server will fetch is a textbook SSRF into the<br />
cloud metadata endpoint, and on a tool that holds cloud credentials that would be a very bad day.</p>
<hr />
<h2 id="the-open-core-model">The Open-Core Model</h2>
<p>BakeX sits alongside the tools that became infrastructure standards by being genuinely usable<br />
before they were commercial:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Model</th>
</tr>
</thead>
<tbody>
<tr>
<td>Terraform / OpenTofu</td>
<td>Core OSS, enterprise features in paid tier</td>
</tr>
<tr>
<td>Cilium / Isovalent</td>
<td>Core OSS, enterprise support/features in paid tier</td>
</tr>
<tr>
<td>Vault / HCP Vault</td>
<td>Core OSS, hosted/enterprise in paid tier</td>
</tr>
<tr>
<td><strong>BakeX</strong></td>
<td>Engine + blueprint + scanner + Pipeline API: Apache 2.0, no paid tier today</td>
</tr>
</tbody>
</table>
<p>Everything taught in this series — the blueprint format, the build pipeline, the compliance<br />
grading, the CI/CD gate — is in the repository. There is no feature held back, because there is<br />
currently nothing to hold it back <em>for</em>. Self-host it, extend it, fork it.</p>
<p>Worth being straight about where the project actually is: BakeX is young. It has signed releases,<br />
SBOMs and provenance attestations, an OpenSSF Scorecard, a published JSON Schema, and over a<br />
thousand tests — the engineering is in good order. What it does not yet have is users. If you&#8217;re<br />
reading this and the shape of the tool fits your problem, you would be early, and early is when<br />
your opinion changes the design.</p>
<p>The repository is at: <strong><a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a></strong></p>
<hr />
<h2 id="what-this-series-taught">What This Series Taught</h2>
<p>EP01 — EP06 in one view:</p>
<table>
<thead>
<tr>
<th>Episode</th>
<th>What you learned</th>
<th>What BakeX does</th>
</tr>
</thead>
<tbody>
<tr>
<td>EP01</td>
<td>Default AMIs are insecure by design</td>
<td>Replaces the default AMI with a hardened golden image</td>
</tr>
<tr>
<td>EP02</td>
<td>Blueprint as code — the 2am skip disappears</td>
<td>HardeningBlueprint YAML, <code class="" data-line="">bakex validate</code> / <code class="" data-line="">bakex build</code></td>
</tr>
<tr>
<td>EP03</td>
<td>One posture, six providers, no drift</td>
<td>18 shipped blueprints; only <code class="" data-line="">target</code> differs across providers</td>
</tr>
<tr>
<td>EP04</td>
<td>Automated OpenSCAP — grade at build time</td>
<td>A–F from the XCCDF score, SARIF 2.1.0 export, baseline compare</td>
</tr>
<tr>
<td>EP05</td>
<td>CI/CD gate — the unhardened image never deploys</td>
<td>Pipeline API: <code class="" data-line="">POST /api/pipeline/scan</code>, parse <code class="" data-line="">.passed</code></td>
</tr>
<tr>
<td>EP06</td>
<td>The platform — OSS, self-hostable, extendable</td>
<td>Apache 2.0, Compose install, blueprints + provider plugins</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>This series closes the OS hardening gap. The same principle — declare desired state, build<br />
reproducibly, verify automatically — applies to every layer of your infrastructure.</p>
<h3 id="write-the-next-blueprint">Write the next blueprint</h3>
<p>The most useful thing you can do with what this series taught is add a blueprint, and it is<br />
genuinely pure YAML — no Python, no engine changes, no build system to learn.</p>
<p>You&#8217;ve spent five episodes on Ubuntu 22.04 CIS Level 1. The natural next one is Level 2 for the<br />
same OS: <a href="https://github.com/invicton/bakex/issues/1"><strong>#1 — Ubuntu 22.04 CIS Level 2</strong></a>. The<br />
issue carries the acceptance criteria and the exact verify command, and the review loop is<br />
<code class="" data-line="">bakex validate</code> returning 0.</p>
<p>If a different distro is closer to what you actually run, the whole set is filed and labelled:<br />
<a href="https://github.com/invicton/bakex/issues?q=is%3Aissue+is%3Aopen+label%3Ablueprint"><strong>good first issues, <code class="" data-line="">blueprint</code> label</strong></a>.<br />
RHEL 9, AlmaLinux 9, Rocky 9, Debian 12, and Amazon Linux 2023 all have gaps. Each one is one<br />
file, and each is the sort of contribution that takes an evening.</p>
<p>GitHub: <a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a></p>
<h3 id="elsewhere-on-the-blog">Elsewhere on the blog</h3>
<p>If you&#8217;ve been following the <a href="/ebpf-series/">eBPF: From Kernel to Cloud series</a>,<br />
EP10 covers what happens when you combine kernel-level observability with the hardened base BakeX<br />
produces: every connection, every process spawn, every file access — visible from the host kernel,<br />
on an OS baseline you can verify.</p>
<p>The next series is the <strong>Purple Team Playbook</strong> — real attack paths against cloud and Kubernetes<br />
infrastructure, how they&#8217;re detected, and how they&#8217;re closed.</p>
<p>Get new episodes in your inbox → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&amp;linkname=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fstratum-os-hardening-platform%2F&#038;title=BakeX%20%E2%80%94%20OS%20Hardening%20as%20a%20Platform" data-a2a-url="https://linuxcent.com/stratum-os-hardening-platform/" data-a2a-title="BakeX — OS Hardening as a Platform"></a></p><p>The post <a href="https://linuxcent.com/stratum-os-hardening-platform/">BakeX — OS Hardening as a Platform</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/stratum-os-hardening-platform/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1834</post-id>	</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: linuxcent.com @ 2026-08-22 12:16:46 by W3 Total Cache
-->