← back to Secrets Manager
cli.js
687 lines
#!/usr/bin/env node
// Secrets Manager CLI — single source of truth for Steve's API tokens.
//
// Usage:
// node cli.js list
// node cli.js add <KEY> <VALUE>
// node cli.js import-paste # reads KEY=VALUE block from stdin
// node cli.js check <KEY>
// node cli.js verify-all # verify configured keys; write data/latest.json
// node cli.js sync # re-fan master → all destinations
// node cli.js audit # scan home for leaked secrets
//
// No deps. Pure node, https module, fs, child_process for grep.
const fs = require('fs');
const path = require('path');
const os = require('os');
const https = require('https');
const crypto = require('crypto');
const { execSync } = require('child_process');
const { spawnSync } = require('child_process');
const ROOT = path.dirname(__filename);
const HOME = os.homedir();
const ROUTES = JSON.parse(fs.readFileSync(path.join(ROOT, 'routes.json'), 'utf8'));
// routes.json carries some routes under .services AND some at top-level (legacy).
// cli.js historically read ONLY .services[key], so ~22 top-level routes
// (PG_DW_ADMIN_PASSWORD, SHOPIFY_ORDERS_TOKEN, TWILIO_*, GOOGLE_DRIVE_*, the
// ANTHROPIC_* keys…) were INVISIBLE to the fan — fanning them wrote only
// master+desktop and silently skipped every destination. routeFor() falls back
// to the top-level entry so all routes fan. (Root cause of the 2026-06-03
// dw_admin rotation half-fire.) Prefer .services on name clash.
function routeFor(key) { return (ROUTES.services && ROUTES.services[key]) || ROUTES[key] || null; }
// DB-password keys whose consumers connect via a full DSN, not a bare var — the
// fan must rewrite the password INSIDE that DSN in each env_file dest.
const DSN_REWRITE = {
// Superset fix (TK-10045, 2026-07-30): the master .env uses DW_ADMIN_PG_PASSWORD /
// DW_ADMIN_DB_PASSWORD, NOT PG_DW_ADMIN_PASSWORD — so a DB rotation pasted under the
// real key names never rewrote the downstream DATABASE_URL DSN (the 2026-06-03
// half-fire). Map ALL three names → so whichever var Steve fills also rewrites the DSN.
PG_DW_ADMIN_PASSWORD: { dsn: 'DATABASE_URL', user: 'dw_admin' }, // legacy name (kept; harmless if unset)
DW_ADMIN_PG_PASSWORD: { dsn: 'DATABASE_URL', user: 'dw_admin' },
DW_ADMIN_DB_PASSWORD: { dsn: 'DATABASE_URL', user: 'dw_admin' },
};
const MASTER_ENV = path.join(ROOT, '.env');
// DESKTOP_ENV (~/Desktop/site-factory.env) retired 2026-06-11 (DTD verdict A) —
// no longer a fan-out destination. See fanOut() for rationale.
const REGISTRY = path.join(ROOT, 'registry.json');
// ─── helpers ────────────────────────────────────────────────────────────
function expand(p) { return p.replace(/^~/, HOME); }
function digest(v) { return v.slice(-4) + ':' + crypto.createHash('sha256').update(v).digest('hex').slice(0, 8); }
function loadRegistry() { return fs.existsSync(REGISTRY) ? JSON.parse(fs.readFileSync(REGISTRY, 'utf8')) : { secrets: {} }; }
function saveRegistry(r) { fs.writeFileSync(REGISTRY, JSON.stringify(r, null, 2)); }
// ─── least-privilege manifests (TK-10045) ────────────────────────────────
// manifests.json (values-free, built by derive-manifests.js) maps each project
// dir → the master-secret keys its CODE references. Used by `regen` to write a
// scoped .env, and by the manifest-aware fanOut guard to stop a rotation from
// silently re-broadening a scoped .env (the silent-decay trap).
const MANIFESTS_PATH = path.join(ROOT, 'manifests.json');
function loadManifests() {
if (!fs.existsSync(MANIFESTS_PATH)) return { base_keys: [], projects: {} };
return JSON.parse(fs.readFileSync(MANIFESTS_PATH, 'utf8'));
}
// Look up the manifest entry that owns a given .env file path (by its dir).
function manifestForEnvPath(man, absEnvPath) {
const dirTilde = path.dirname(absEnvPath).replace(HOME, '~');
return man.projects[dirTilde] || null;
}
// Fan-out guard: should we SKIP writing `key` to the .env at `absPath`?
// Only active when SCOPED_FANOUT=1 (default OFF → behavior unchanged until Steve
// enables scoping fleet-wide). NEVER skips a review_required/dynamic project (its
// manifest is known-incomplete) or an unknown path (fail-open — fan everything).
// Skips only for a narrowable project whose manifest does not list the key.
let _MAN_CACHE = null;
function scopedSkip(absPath, key) {
if (process.env.SCOPED_FANOUT !== '1') return false;
// NEVER skip a key that drives a DSN (e.g. PG_DW_ADMIN_PASSWORD → DATABASE_URL):
// consumers read the DSN, not the bare var, so the manifest won't list the key —
// skipping would drop the DSN password rewrite and crash the app on next restart
// (contrarian Hole 2, reproduced; the exact 2026-06-03 half-fire shape).
if (DSN_REWRITE[key]) return false;
if (!_MAN_CACHE) _MAN_CACHE = loadManifests();
const e = manifestForEnvPath(_MAN_CACHE, absPath);
if (!e || e.review_required || e.missing_dir) return false; // fail-open
const allow = new Set([...(e.derived || []), ...(_MAN_CACHE.base_keys || [])]);
if (allow.has(key)) return false;
console.error(` ⚠ scoped-skip ${key} → ${absPath.replace(HOME,'~')} (not in manifest)`);
return true;
}
function loadEnvFile(p) {
if (!fs.existsSync(p)) return {};
const out = {};
for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
// dotenv treats `#` as a comment delimiter unless the value is quoted, and
// chokes on space-edge values + literal newlines. Auto-quote anything that
// would parse short. Bug: previously a sync stripped quotes off
// MAILING_ADDRESS="…#102…" → dotenv parsed only "Designer Wallcoverings…Bl "
// and the CAN-SPAM compliance gate fail-closed in production.
function envEscape(v) {
const s = String(v);
if (s === '') return '';
// Any whitespace requires quoting for shell-source consumers. Without this, an Authorization
// value such as `Basic abc...` is parsed by zsh as IG_AGENT_AUTH=Basic plus a command named
// `abc...`, silently exporting only "Basic" while Node's dotenv parser still appears healthy.
if (/[#"'\n\r\s]/.test(s)) return '"' + s.replace(/(["\\])/g, '\\$1') + '"';
return s;
}
function writeEnvFile(p, kv, preserveComments = true) {
fs.mkdirSync(path.dirname(p), { recursive: true });
let body = '';
if (preserveComments && fs.existsSync(p)) {
// Preserve existing lines, replacing matching keys
const seen = new Set();
for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${envEscape(kv[m[1]])}\n`; seen.add(m[1]); }
else body += line + '\n';
}
for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${envEscape(kv[k])}\n`;
body = body.replace(/\n+$/, '\n');
} else {
for (const [k, v] of Object.entries(kv)) body += `${k}=${envEscape(v)}\n`;
}
// Backup-before-overwrite guard (2026-08-11): never destroy a prior secret value in
// place. If the file exists and its content is ACTUALLY changing, snapshot it to a
// timestamped .bak first (0600), then prune to the last 5 for this file. Born from the
// ZENDESK_CHAT_CLIENT_SECRET incident — an `add` overwrote a value with no recovery path.
if (fs.existsSync(p)) {
const prev = fs.readFileSync(p, 'utf8');
if (prev !== body) {
const bak = `${p}.bak.${Date.now()}`;
try {
fs.copyFileSync(p, bak); fs.chmodSync(bak, 0o600);
const dir = path.dirname(p), base = path.basename(p) + '.bak.';
const olds = fs.readdirSync(dir).filter(f => f.startsWith(base)).sort();
for (const o of olds.slice(0, -5)) { try { fs.unlinkSync(path.join(dir, o)); } catch {} }
} catch {}
}
}
fs.writeFileSync(p, body);
try { fs.chmodSync(p, 0o600); } catch {}
}
// Rewrite the password segment of a DSN var (e.g. DATABASE_URL=postgres://user:PW@host)
// inside an env-file body, for the given DB user. The new pw is URL-encoded so base64
// chars (+ / =) don't corrupt the URL userinfo. Returns {body, changed}.
//
// WHY this exists: dw_admin consumers connect via a full DATABASE_URL DSN, NOT a bare
// *_PASSWORD var. A fan that only sets the bare var leaves DATABASE_URL holding the OLD
// pw → restart → FATAL auth. That was the 2026-06-03 rotation half-fire. The pg-rotation
// route now carries `dsn: "DATABASE_URL"` so the fan rewrites the live DSN too.
function rewriteDsnPassword(text, dsnVar, dbUser, newPw) {
if (!text) return { body: text, changed: 0 };
const enc = encodeURIComponent(newPw);
let changed = 0;
const re = new RegExp('^(\\s*' + dsnVar + '\\s*=\\s*["\']?)(postgres(?:ql)?:\\/\\/' + dbUser + ':)([^@]*)(@)', 'i');
const body = text.split('\n').map((line) => {
const m = line.match(re);
if (m) { changed++; return m[1] + m[2] + enc + m[4] + line.slice(m[0].length); }
return line;
}).join('\n');
return { body, changed };
}
// Remote env via SSH. Hosts must have SSH key auth set up; we never prompt
// for a password (BatchMode=yes). Connect timeout caps stalls at 8s.
function readRemoteEnvFile(user, host, p) {
const r = spawnSync('ssh', [
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=8',
'-o', 'StrictHostKeyChecking=accept-new',
`${user}@${host}`,
`cat ${p} 2>/dev/null || true`,
], { encoding: 'utf8' });
return r.status === 0 ? (r.stdout || '') : '';
}
function parseEnvString(s) {
const out = {};
for (const line of s.split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
function serializeEnvBody(existingText, kv) {
// Preserve comments + ordering, replace matching keys, append new ones.
// envEscape() handles `#`/quote/space-edge values so dotenv parses them whole.
let body = '';
const seen = new Set();
if (existingText) {
for (const line of existingText.split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
if (m && kv[m[1]] !== undefined) { body += `${m[1]}=${envEscape(kv[m[1]])}\n`; seen.add(m[1]); }
else body += line + '\n';
}
}
for (const k of Object.keys(kv)) if (!seen.has(k)) body += `${k}=${envEscape(kv[k])}\n`;
return body.replace(/\n+$/, '\n');
}
function writeRemoteEnvFile(user, host, p, body) {
// Use ssh + tee with stdin so we never pass the secret on the command line.
// Remote dir is ensured first; permissions tightened to 600 after write.
const dir = p.replace(/\/[^/]+$/, '') || '.';
const remoteCmd = `mkdir -p '${dir}' && cat > '${p}' && chmod 600 '${p}'`;
const r = spawnSync('ssh', [
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=8',
'-o', 'StrictHostKeyChecking=accept-new',
`${user}@${host}`,
remoteCmd,
], { input: body, encoding: 'utf8' });
if (r.status !== 0) {
throw new Error(`ssh write failed (${user}@${host}:${p}): ${(r.stderr || '').slice(0, 200)}`);
}
}
function httpsReq(url, opts = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = https.request({
hostname: u.hostname, path: u.pathname + u.search, method: opts.method || 'GET',
headers: opts.headers || {},
}, res => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => resolve({ status: res.statusCode, body }));
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
// ─── verify ─────────────────────────────────────────────────────────────
async function verifyToken(key, value) {
const cfg = routeFor(key)?.verify;
if (!cfg) return { ok: true, skipped: true };
const headers = {};
if (cfg.auth === 'bearer') headers['Authorization'] = `Bearer ${value}`;
if (cfg.auth === 'basic-sk') headers['Authorization'] = 'Basic ' + Buffer.from(value + ':').toString('base64');
if (cfg.auth === 'x-api-key') { headers['x-api-key'] = value; headers['anthropic-version'] = '2023-06-01'; }
// generic 'header' mode: caller specifies cfg.headerName (e.g. 'xi-api-key' for ElevenLabs)
if (cfg.auth === 'header' && cfg.headerName) headers[cfg.headerName] = value;
if (cfg.body) headers['Content-Type'] = 'application/json';
// 'url' / 'query' auth: substitute {value} in the URL (for APIs that take the key as a query param)
const url = (cfg.auth === 'url' || cfg.auth === 'query')
? cfg.url.replace('{value}', encodeURIComponent(value))
: cfg.url;
try {
const r = await httpsReq(url, { method: cfg.method, headers, body: cfg.body });
// Anthropic-specific: 400 with "credit balance" means the key auth'd but the account is out of credits.
// That's still a valid key; persist but flag with status.
const creditOk = r.status === 400 && /credit balance|insufficient/i.test(r.body);
// Some providers auth the key but reject the probe's minimal body with a
// schema/validation status (e.g. typesafe.ai returns 422 "Field required"
// on an empty body while a bad key would 401). A route may whitelist such
// statuses via cfg.okStatuses, but okStatuses now REQUIRES a paired
// cfg.bodyMustContain to take effect — a whitelisted non-2xx status can
// never alone certify a key; the positive body proof must also match.
const okStatus = Array.isArray(cfg.okStatuses) && cfg.okStatuses.includes(r.status) && !!cfg.bodyMustContain;
let httpOk = (r.status >= 200 && r.status < 300) || creditOk || okStatus;
// Body-content guards (e.g. Purelymail returns HTTP 200 + {"type":"error"} on bad token).
// Cap the probe at 4096 bytes to bound regex worst-case.
const probe = (r.body || '').slice(0, 4096);
if (httpOk && cfg.bodyMustNotContain && new RegExp(cfg.bodyMustNotContain, 'i').test(probe)) httpOk = false;
if (httpOk && cfg.bodyMustContain && !new RegExp(cfg.bodyMustContain, 'i').test(probe)) httpOk = false;
return { ok: httpOk, status: r.status, body: r.body.slice(0, 200) };
} catch (e) {
return { ok: false, error: e.message };
}
}
function configuredSecretKeys(routes = ROUTES) {
const isSecretKey = (key) => /^[A-Z][A-Z0-9_]*$/.test(key);
const keys = new Set(Object.keys(routes.services || {}).filter(isSecretKey));
for (const [key, value] of Object.entries(routes)) {
if (isSecretKey(key) && value && typeof value === 'object') keys.add(key);
}
return [...keys].sort();
}
async function buildVerifyAllReport({ master, routes = ROUTES, verifier = verifyToken, now = new Date(), timeoutMs = 10000 }) {
const checks = [];
const lookup = (key) => (routes.services && routes.services[key]) || routes[key] || null;
for (const key of configuredSecretKeys(routes)) {
const cfg = lookup(key);
const value = master[key];
if (typeof value !== 'string' || !value.trim()) {
checks.push({ key, outcome: cfg?.verify ? 'FAIL' : 'WARN', reason: 'missing-from-master' });
continue;
}
if (!cfg?.verify) {
checks.push({ key, outcome: 'WARN', reason: 'no-verify-endpoint' });
continue;
}
const result = await boundedVerify(verifier, key, value, timeoutMs);
checks.push(result.ok
? { key, outcome: 'PASS', http_status: result.status || null }
: { key, outcome: 'FAIL', http_status: result.status || null, reason: result.reason || 'provider-rejected' });
}
const counts = checks.reduce((acc, check) => { acc[check.outcome]++; return acc; }, { PASS: 0, WARN: 0, FAIL: 0 });
return {
schema_version: 1,
generated_at: now.toISOString(),
status: counts.FAIL ? 'FAIL' : counts.WARN ? 'WARN' : 'PASS',
summary: { total: checks.length, ...counts },
checks,
};
}
async function boundedVerify(verifier, key, value, timeoutMs = 10000) {
const safeTimeoutMs = Number.isSafeInteger(timeoutMs) && timeoutMs >= 1 && timeoutMs <= 60000
? timeoutMs
: 10000;
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve({ ok: false, reason: 'timeout' }), safeTimeoutMs);
});
try {
const result = await Promise.race([
Promise.resolve().then(() => verifier(key, value)).catch(() => ({ ok: false, reason: 'verifier-error' })),
timeout,
]);
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
return { ok: false, reason: 'malformed-result' };
}
const status = Number.isInteger(result.status) && result.status >= 100 && result.status <= 599
? result.status
: null;
if (result.ok) return { ok: true, status };
const reason = result.reason === 'timeout' || result.reason === 'verifier-error'
? result.reason
: result.error ? 'network-error' : 'provider-rejected';
return { ok: false, status, reason };
} finally {
clearTimeout(timer);
}
}
async function cmdVerifyAll(options = {}) {
const report = await buildVerifyAllReport({
master: options.master || loadEnvFile(MASTER_ENV),
routes: options.routes || ROUTES,
verifier: options.verifier || verifyToken,
now: options.now || new Date(),
timeoutMs: options.timeoutMs,
});
const outputPath = options.outputPath || path.join(ROOT, 'data', 'latest.json');
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const tempPath = `${outputPath}.tmp-${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
fs.chmodSync(tempPath, 0o600);
fs.renameSync(tempPath, outputPath);
fs.chmodSync(outputPath, 0o600);
console.log(`verify-all ${report.status} — PASS=${report.summary.PASS} WARN=${report.summary.WARN} FAIL=${report.summary.FAIL}`);
console.log(`report: ${outputPath}`);
return report;
}
// ─── fan-out ────────────────────────────────────────────────────────────
function fanOut(key, value) {
const destinations = routeFor(key)?.destinations || [];
const written = [];
// Master always
const master = loadEnvFile(MASTER_ENV); master[key] = value;
writeEnvFile(MASTER_ENV, master, false); written.push(MASTER_ENV);
// Desktop master mirror REMOVED 2026-06-11 (DTD verdict A): the Desktop is a
// high-exposure location (Time Machine / iCloud Desktop-sync / screenshots),
// chmod 600 gives no protection against code running as the user (the
// prompt-injection threat that prompted this), and the mirror was pure
// redundancy with MASTER_ENV. Canonical master at ~/Projects/secrets-manager/.env
// remains the single source of truth. Do NOT reintroduce a Desktop copy.
for (const d of destinations) {
if (d.type === 'project') {
// Local project (no host) — original behavior.
// Remote project (d.host set) — push via SSH; failures log + skip,
// they don't blow up the rest of the fan-out.
if (d.host) {
const user = d.user || 'root';
try {
const existing = readRemoteEnvFile(user, d.host, d.path);
const cur = parseEnvString(existing); cur[key] = value;
const body = serializeEnvBody(existing, cur);
writeRemoteEnvFile(user, d.host, d.path, body);
written.push(`ssh:${user}@${d.host}:${d.path}`);
} catch (e) {
console.error(` ⚠ remote skip ${user}@${d.host}:${d.path} — ${e.message}`);
}
} else {
const p = expand(d.path);
if (scopedSkip(p, key)) continue;
const cur = loadEnvFile(p); cur[key] = value;
writeEnvFile(p, cur, true); written.push(p);
}
} else if (d.type === 'env_file') {
// Local env file. Sets the bare key, AND — when this key feeds a DSN
// (per-dest `d.dsn`, or the code-driven DSN_REWRITE map) — surgically
// rewrites the password inside that DSN var (e.g. DATABASE_URL) for the
// DB user. The dw_admin rotation path NEEDS this: consumers read
// DATABASE_URL, not the bare *_PASSWORD var. (Pre-2026-06-09 the fan had
// no env_file branch at all → these dests were silently skipped.)
const p = expand(d.path);
if (!fs.existsSync(p)) { console.error(` ⚠ env_file skip (missing): ${p}`); continue; }
if (!d.dsn && scopedSkip(p, key)) continue; // never skip a per-dest DSN rewrite
let text = fs.readFileSync(p, 'utf8');
let note = '';
const dsnSpec = d.dsn ? { dsn: d.dsn, user: d.dsnUser || 'dw_admin' } : DSN_REWRITE[key];
if (dsnSpec) {
const { body, changed } = rewriteDsnPassword(text, dsnSpec.dsn, dsnSpec.user, value);
text = body;
note = changed ? ` [DSN ${dsnSpec.dsn}×${changed}]` : ` [⚠ DSN ${dsnSpec.dsn} NOT FOUND]`;
}
// serializeEnvBody only rewrites keys present in the kv map, so the
// already-rewritten DSN line is preserved verbatim (not re-parsed).
const outKey = d.key || key; // honor per-destination key remap (e.g. SHOPIFY_ADMIN_TOKEN -> SHOPIFY_ADMIN_ACCESS_TOKEN)
const finalBody = serializeEnvBody(text, d.dsnOnly ? {} : { [outKey]: value });
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, finalBody);
try { fs.chmodSync(p, 0o600); } catch {}
written.push(p + note);
} else if (d.type === 'skill') {
const p = path.join(HOME, '.claude/skills', d.name, '.env');
if (scopedSkip(p, key)) continue;
const cur = loadEnvFile(p); cur[key] = value;
writeEnvFile(p, cur, true); written.push(p);
} else if (d.type === 'site_local') {
for (const dom of d.domains) {
const p = expand(`~/Projects/site-factory/sites/${dom}/app/.env.local`);
if (scopedSkip(p, key)) continue;
const cur = loadEnvFile(p); cur[key] = value;
writeEnvFile(p, cur, true); written.push(p);
}
} else if (d.type === 'mcp') {
const cfgPath = path.join(HOME, '.claude.json');
if (!fs.existsSync(cfgPath)) continue;
const bak = `${cfgPath}.bak.${Date.now()}`;
fs.copyFileSync(cfgPath, bak);
// Prune stale secret-bearing backups — keep only the last 5. Each is a full
// copy of ~/.claude.json (all MCP env secrets); 135 had accumulated in $HOME,
// outside any audit/gitignore scope (contrarian Hole 3, reproduced).
try {
const baks = fs.readdirSync(HOME).filter(f => f.startsWith('.claude.json.bak.'))
.sort().reverse();
for (const old of baks.slice(5)) { try { fs.unlinkSync(path.join(HOME, old)); } catch {} }
} catch {}
const j = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
const sv = (j.mcpServers && j.mcpServers[d.server]) || null;
if (sv) {
sv.env = sv.env || {};
sv.env[d.key || key] = value;
fs.writeFileSync(cfgPath, JSON.stringify(j, null, 2));
written.push(`${cfgPath}#mcpServers.${d.server}.env.${d.key || key}`);
}
}
}
return written;
}
// ─── commands ───────────────────────────────────────────────────────────
async function cmdAdd(key, value) {
if (!key || !value) { console.error('usage: add <KEY> <VALUE>'); process.exit(2); }
const v = await verifyToken(key, value);
if (!v.ok) {
console.error(`✗ verify failed for ${key}: ${v.status || v.error || ''}`);
if (v.body) console.error(' ' + v.body);
process.exit(1);
}
const written = fanOut(key, value);
const r = loadRegistry();
r.secrets[key] = {
digest: digest(value),
label: routeFor(key)?.label || key,
last_updated: new Date().toISOString(),
validated: !v.skipped,
verify_status: v.status || null,
written_to: written,
};
saveRegistry(r);
console.log(`[secrets-manager]`);
console.log(` service: ${key}`);
console.log(` action: add`);
console.log(` validated: ${v.skipped ? 'skipped (no verify endpoint)' : 'yes'}`);
console.log(` digest: …${r.secrets[key].digest}`);
console.log(` written_to: ${written.length} destinations`);
for (const w of written) console.log(` - ${w}`);
}
async function cmdImportPaste() {
const stdin = fs.readFileSync(0, 'utf8');
const lines = stdin.split('\n').map(l => l.trim()).filter(Boolean);
const adds = [];
for (const line of lines) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
if (m && m[2] && !m[2].startsWith('#')) adds.push([m[1], m[2].replace(/^["']|["']$/g, '')]);
}
if (!adds.length) { console.error('no KEY=value lines found in stdin'); process.exit(2); }
console.log(`importing ${adds.length} key(s): ${adds.map(([k]) => k).join(', ')}`);
for (const [k, v] of adds) {
try { await cmdAdd(k, v); } catch (e) { console.error(` ${k}: ${e.message}`); }
}
}
function cmdList() {
const r = loadRegistry();
console.log('Known services:');
for (const k of Object.keys(ROUTES.services)) {
const e = r.secrets[k];
if (e) console.log(` ✓ ${k.padEnd(28)} ${e.label.padEnd(28)} …${e.digest} ${e.last_updated.slice(0,10)}`);
else console.log(` ✗ ${k.padEnd(28)} ${ROUTES.services[k].label.padEnd(28)} <not set>`);
}
}
async function cmdCheck(key) {
if (!key) { console.error('usage: check <KEY>'); process.exit(2); }
const master = loadEnvFile(MASTER_ENV);
const v = master[key];
if (!v) { console.error(`${key}: not in master`); process.exit(1); }
const r = await verifyToken(key, v);
console.log(`${key}: ${r.ok ? 'VALID' : 'INVALID'} status=${r.status || r.error || 'n/a'}`);
}
function cmdSync() {
const master = loadEnvFile(MASTER_ENV);
let total = 0;
// Iterate BOTH .services AND legacy top-level routed keys — cmdSync previously
// only walked .services, so ~47 top-level routes (SHOPIFY_ORDERS_TOKEN, SPOONFLOWER_*,
// GOOGLE_DRIVE_*, TWILIO_*, the OPENAI/ELEVENLABS legacy entries…) never re-fanned
// on `sync` — the same .services-only blind spot that caused the 2026-06-03 half-fire.
const META = new Set(['_comment', 'services', 'leak_patterns']);
const topLevel = Object.keys(ROUTES).filter(k => !META.has(k) && ROUTES[k] && ROUTES[k].destinations);
const allKeys = [...new Set([...Object.keys(ROUTES.services), ...topLevel])];
for (const k of allKeys) {
if (master[k]) total += fanOut(k, master[k]).length;
}
console.log(`sync: re-wrote ${total} destination entries from master`);
// REMOTE fan-out is a SEPARATE, EXPLICIT step (never an automatic side-effect of sync):
// node scripts/push-remote.js # SSH remote-routed secrets to their server(s) + pm2 reload
// Kept opt-in on purpose — a routine local sync must not silently write to a prod host.
if (fs.existsSync(path.join(ROOT, 'remote-routes.json'))) {
console.log('sync: remote destinations exist — run `node scripts/push-remote.js` to push them to prod.');
}
}
function cmdAudit() {
const patterns = ROUTES.leak_patterns || [];
const roots = [
path.join(HOME, '.claude/skills'),
path.join(HOME, '.claude/agents'),
path.join(HOME, 'Projects'),
path.join(HOME, 'cncp-starter'),
];
const exts = '\\.(md|json|js|ts|tsx|sh|py|env|local|toml|yaml|yml)$';
const findings = [];
for (const root of roots) {
if (!fs.existsSync(root)) continue;
let files;
try {
files = execSync(
`find "${root}" -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/dist/*" -not -path "*/.next/*" 2>/dev/null | grep -E '${exts}'`,
{ encoding: 'utf8', maxBuffer: 1024 * 1024 * 50 }
).split('\n').filter(Boolean);
} catch { continue; }
for (const f of files) {
let text;
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
// Skip our own .env files (they SHOULD have secrets, that's their job)
if (f.endsWith('/.env') || f.endsWith('/.env.local')) continue;
// Skip the master + registry (the manager itself)
if (f === MASTER_ENV || f === REGISTRY || f === path.join(ROOT, 'routes.json')) continue;
for (const p of patterns) {
const re = new RegExp(p.regex, 'g');
let m;
while ((m = re.exec(text)) !== null) {
const captured = m[1] || m[0];
const line = text.slice(0, m.index).split('\n').length;
findings.push({ file: f, line, pattern: p.name, last4: captured.slice(-4) });
}
}
}
}
if (!findings.length) { console.log('audit: no leaked secrets found'); return; }
console.log(`audit: ${findings.length} finding(s):`);
for (const f of findings) console.log(` ${f.pattern.padEnd(24)} ${f.file}:${f.line} …${f.last4}`);
}
// ─── regen (least-privilege scoping, TK-10045) ───────────────────────────
// Writes each project a scoped .env = manifest ∩ master. Default --dry-run just
// reports the diff. --apply backs up every .env, REMOVES only master-secret keys
// the project's code doesn't reference (app-local non-secret vars are never
// touched — they're not in master), and REFUSES review_required/dynamic projects
// (their manifest is known-incomplete). --project=<~/Projects/x> scopes to one.
function cmdRegen(args) {
const apply = args.includes('--apply');
const addMissing = args.includes('--add-missing'); // opt-in: provision keys the code refs but .env lacks
const projFilter = (args.find(a => a.startsWith('--project=')) || '').split('=')[1];
const man = loadManifests();
if (!Object.keys(man.projects || {}).length) { console.error('no manifests.json — run: node derive-manifests.js'); process.exit(2); }
const master = loadEnvFile(MASTER_ENV);
const masterKeys = new Set(Object.keys(master));
const base = new Set(man.base_keys || []);
const reg = loadRegistry(); reg.scoped = reg.scoped || {};
const t = { projects: 0, remove: 0, add: 0, applied: 0, gatedReview: 0 };
const rows = [];
for (const [name, e] of Object.entries(man.projects)) {
if (projFilter && name !== projFilter) continue;
if (e.missing_dir) continue;
const p = expand(e.env_path);
if (p === MASTER_ENV) continue; // NEVER scope the master .env (would strip all 196)
const current = loadEnvFile(p);
const curKeys = new Set(Object.keys(current));
const allow = new Set([...(e.derived || []), ...base]);
// REMOVE = the blast-radius win: master keys in the .env the code never references.
const removed = [...curKeys].filter(k => masterKeys.has(k) && !allow.has(k)).sort();
// MISSING = advisory: code refs these master keys but the .env lacks them (service maybe
// broken, or gets them elsewhere). NOT added unless --add-missing, because provisioning a
// live secret into a new .env EXPANDS blast radius — the opposite of this pass's purpose.
const missing = (e.derived || []).filter(k => masterKeys.has(k) && !curKeys.has(k)).sort();
const added = addMissing ? missing : [];
if (!removed.length && !added.length) continue;
rows.push({ name, review: e.review_required, removed, missing, added });
t.projects++; t.remove += removed.length; t.add += added.length;
if (apply) {
if (e.review_required) { t.gatedReview++; continue; }
if (fs.existsSync(p)) fs.copyFileSync(p, `${p}.pre-scope.${Date.now()}`);
const removeSet = new Set(removed);
let body = (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '').split('\n')
.filter(l => { const m = l.match(/^([A-Z_][A-Z0-9_]*)=/); return !(m && removeSet.has(m[1])); })
.join('\n');
if (!body.endsWith('\n')) body += '\n';
for (const k of added) body += `${k}=${envEscape(master[k])}\n`;
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body); try { fs.chmodSync(p, 0o600); } catch {}
const scopedKeys = [...allow].filter(k => masterKeys.has(k)).sort();
reg.scoped[name] = { env_path: e.env_path, scoped_digest: crypto.createHash('sha256').update(scopedKeys.join(',')).digest('hex').slice(0, 12), key_count: scopedKeys.length, applied: new Date().toISOString() };
t.applied++;
}
}
rows.sort((a, b) => b.removed.length - a.removed.length);
console.log(`regen ${apply ? 'APPLY' : 'DRY-RUN'}${addMissing ? ' +add-missing' : ' (remove-only)'} — ${t.projects} project(s), ${t.remove} over-shared keys removed${addMissing ? `, ${t.add} added` : ''}`);
for (const r of rows.slice(0, 40)) {
console.log(` ${r.review ? '⟲REVIEW ' : ' '}${r.name}`);
if (r.removed.length) console.log(` − ${r.removed.length} over-shared: ${r.removed.slice(0, 12).join(', ')}${r.removed.length > 12 ? ' …' : ''}`);
if (r.added.length) console.log(` + ${r.added.length} added: ${r.added.join(', ')}`);
else if (r.missing.length) console.log(` ? ${r.missing.length} code-refs-but-.env-lacks (advisory): ${r.missing.slice(0, 8).join(', ')}${r.missing.length > 8 ? ' …' : ''}`);
}
if (rows.length > 40) console.log(` … +${rows.length - 40} more`);
if (apply) { saveRegistry(reg); console.log(`applied to ${t.applied} project(s); ${t.gatedReview} review_required SKIPPED (gated — hand-review + migration runbook).`); }
else console.log(`\ndry-run only. ⟲REVIEW = dynamic env access, SKIPPED by --apply. '?' rows are advisory (not auto-added). Back up + reload + /healthz-verify per project before applying customer-facing ones.`);
}
// ─── dispatch ───────────────────────────────────────────────────────────
async function main(argv = process.argv.slice(2), options = {}) {
const [cmd, ...rest] = argv;
switch (cmd) {
case 'add': await cmdAdd(rest[0], rest[1]); break;
case 'import-paste': await cmdImportPaste(); break;
case 'list': cmdList(); break;
case 'check': await cmdCheck(rest[0]); break;
case 'verify-all': {
const report = await cmdVerifyAll(options.verifyAllOptions);
return report.status === 'FAIL' ? 1 : 0;
}
case 'sync': cmdSync(); break;
case 'audit': cmdAudit(); break;
case 'regen': cmdRegen(rest); break;
default:
console.error('usage: cli.js <add|import-paste|list|check|verify-all|sync|audit|regen> [args]');
process.exit(2);
}
return 0;
}
if (require.main === module) main()
.then((code) => { process.exitCode = code; })
.catch(e => { console.error('ERR:', e.message); process.exitCode = 1; });
module.exports = { boundedVerify, buildVerifyAllReport, cmdVerifyAll, configuredSecretKeys, main };