DEV Community

Arthur Palyan
Arthur Palyan

Posted on

We Shipped Our Passwords to npm (And Built a System So It Never Happens Again)

We build The Nervous System - an MCP server that enforces behavioral guardrails on LLM agents. It has security audit tools built in. It scans for exposed secrets, hardcoded paths, and misconfigurations.

Then we published it to npm with our passwords in the source code.

How It Happened

The Nervous System has 18 tools. One of them is security_audit - it scans project files for leaked credentials, open ports, and config mistakes. It works great.

But it scans the user's files. It never scans itself.

During development on our VPS, internal references to family data directories, hardcoded /root/ paths, and personal naming conventions crept into the codebase. The security audit caught none of it because we never pointed it at its own source.

The Fix: pre_publish_audit

We wrote a tool that scans the Nervous System's own source before every npm publish. Here's the actual implementation:

function runPrePublishAudit(sourceFile) {
  const findings = [];
  const file = sourceFile || __filename;
  const content = fs.readFileSync(file, 'utf8');
  const lines = content.split('\n');

  // 1. Hardcoded absolute paths (non-portable)
  lines.forEach((line, idx) => {
    if (line.trim().startsWith('//')) return;
    if (line.includes('description:')) return;
    if (line.match(/['\"`]\/root\//)) {
      findings.push({
        type: 'hardcoded_path',
        line: idx + 1,
        preview: line.trim().substring(0, 100),
        fix: 'Use projectPath() or configurable path'
      });
    }
  });

  // 2. Personal data that should not ship
  const personalPatterns = [
    { name: 'email_address', pat: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g },
    { name: 'phone_number', pat: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g },
    { name: 'ip_address', pat: /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g },
  ];

  // 3. Internal naming that should be generic
  const internalTerms = [
    'family-data', 'family-home', 'family-logs',
    'PAPA_FULL', 'PAPA_READ', 'ARTHUR_CHAT_ID',
  ];

  // 4. GATE: Block publish if critical findings
  const critical = findings.filter(f =>
    f.type === 'personal_data' ||
    (f.type === 'hardcoded_path' && !f.preview.includes('description'))
  );

  return {
    status: critical.length > 0 ? 'BLOCKED_critical_findings' : 'ready_to_publish',
    total_findings: findings.length,
    critical_count: critical.length,
    findings: findings,
    recommendation: critical.length > 0 ?
      'DO NOT PUBLISH. Fix critical findings first.' :
      'Clean. Safe to publish.'
  };
}
Enter fullscreen mode Exit fullscreen mode

The key design decisions:

  • It scans itself by default (sourceFile || __filename) - the auditor audits itself
  • It skips comments and descriptions - no false positives from documentation
  • It gates on severity - warnings let you publish, critical findings block you
  • It returns structured data - machine-readable so CI can consume it

The Regression Test: ns-smoke-test.js

One audit tool is not enough. We also wrote a smoke test that runs after every restart and before every publish:

// Excerpt from ns-smoke-test.js
async function testSourceCode() {
  const content = fs.readFileSync(sourceFile, 'utf8');

  // Check for leaked passwords
  const passwordPatterns = [
    /KnownPassword1/i,
    /KnownPassword2/i,
  ];
  let passwordClean = true;
  for (const pat of passwordPatterns) {
    if (pat.test(content)) {
      failed++;
      passwordClean = false;
      failures.push({
        test: 'password_check',
        error: 'Password pattern found: ' + pat.source
      });
    }
  }

  // Pre-publish mode also checks hardcoded paths
  if (PRE_PUBLISH) {
    const lines = content.split('\n');
    let hardcodedPaths = 0;
    lines.forEach((line, idx) => {
      if (line.trim().startsWith('//')) return;
      if (line.includes("'/root/") || line.includes('"root/')) {
        hardcodedPaths++;
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The smoke test also does a fresh install test - it installs the package from npm into a temp directory, starts the server, and verifies no passwords leaked:

async function testFreshInstall() {
  const tmpDir = '/tmp/ns-fresh-test-' + Date.now();
  fs.mkdirSync(tmpDir, { recursive: true });
  execSync('cd ' + tmpDir + ' && npm init -y && npm install mcp-nervous-system');

  const content = fs.readFileSync(indexPath, 'utf8');
  if (/KnownPassword1|KnownPassword2/i.test(content)) {
    log('FAIL: Passwords found in fresh npm install!');
  }
}
Enter fullscreen mode Exit fullscreen mode

The Configurable Path Fix

The third fix was making all paths configurable. Instead of hardcoding /root/family-data/, we use a projectPath() function that reads from a config file:

// Before: hardcoded, only works on our VPS
const data = fs.readFileSync('/root/family-data/roles.json');

// After: configurable, works anywhere
const data = fs.readFileSync(projectPath('data_dir') + '/roles.json');
Enter fullscreen mode Exit fullscreen mode

Users drop a nervous-system.config.json in their project root and the MCP server adapts.

The Lesson

Security tools have a blind spot: themselves. If your linter does not lint its own source, if your audit does not audit its own dependencies, if your scanner does not scan its own config - you have a gap.

The fix is simple: make every security tool's first target itself.

Our publish pipeline now looks like:

  1. node ns-smoke-test.js --pre-publish - regression test
  2. pre_publish_audit tool call - source scan
  3. Only then: npm publish

Three layers. The passwords are gone. The paths are configurable. And the auditor now audits itself.


The Nervous System is open source. It's an MCP server that gives LLM agents guardrails, audit tools, and behavioral enforcement. If you're running AI agents in production, check it out.

npm install mcp-nervous-system

Top comments (0)