← back to Secrets Manager
fix: envEscape() so #-containing values keep quotes through sync
f2bf48303d064e4ad9421065f65026078d5b4731 · 2026-05-06 14:38:39 -0700 · SteveStudio2
Bug: loadEnvFile() strips quotes on read but writeEnvFile()/serializeEnvBody() never re-add them, so any value with a # (e.g. MAILING_ADDRESS="… 15442 Ventura Bl #102 …") gets de-quoted on the next sync and dotenv silently parses only the prefix, fail-closing every gate that depended on the full value.
Symptom in prod (NPH, 2026-05-06): MAILING_ADDRESS sync flipped CAN-SPAM compliance from PASS → no_mailing_address even though the Kamatera .env file looked correct on cat.
Fix: envEscape() wraps any value containing #, quotes, newlines, or edge-whitespace in double quotes (escaping inner quotes/backslashes). Both writeEnvFile() and serializeEnvBody() now run values through it.
Files touched
Diff
commit f2bf48303d064e4ad9421065f65026078d5b4731
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 6 14:38:39 2026 -0700
fix: envEscape() so #-containing values keep quotes through sync
Bug: loadEnvFile() strips quotes on read but writeEnvFile()/serializeEnvBody() never re-add them, so any value with a # (e.g. MAILING_ADDRESS="… 15442 Ventura Bl #102 …") gets de-quoted on the next sync and dotenv silently parses only the prefix, fail-closing every gate that depended on the full value.
Symptom in prod (NPH, 2026-05-06): MAILING_ADDRESS sync flipped CAN-SPAM compliance from PASS → no_mailing_address even though the Kamatera .env file looked correct on cat.
Fix: envEscape() wraps any value containing #, quotes, newlines, or edge-whitespace in double quotes (escaping inner quotes/backslashes). Both writeEnvFile() and serializeEnvBody() now run values through it.
---
cli.js | 30 ++++++++++++++++++++++++------
1 file changed, 24 insertions(+), 6 deletions(-)
diff --git a/cli.js b/cli.js
index efb1617..b277e7d 100755
--- a/cli.js
+++ b/cli.js
@@ -40,6 +40,17 @@ function loadEnvFile(p) {
}
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 '';
+ if (/[#"'\n\r]|^\s|\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 = '';
@@ -48,13 +59,13 @@ function writeEnvFile(p, kv, preserveComments = true) {
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]}=${kv[m[1]]}\n`; seen.add(m[1]); }
+ 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}=${kv[k]}\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}=${v}\n`;
+ for (const [k, v] of Object.entries(kv)) body += `${k}=${envEscape(v)}\n`;
}
fs.writeFileSync(p, body);
try { fs.chmodSync(p, 0o600); } catch {}
@@ -81,16 +92,17 @@ function parseEnvString(s) {
}
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]}=${kv[m[1]]}\n`; seen.add(m[1]); }
+ 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}=${kv[k]}\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) {
@@ -147,7 +159,13 @@ async function verifyToken(key, value) {
// 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);
- return { ok: (r.status >= 200 && r.status < 300) || creditOk, status: r.status, body: r.body.slice(0, 200) };
+ let httpOk = (r.status >= 200 && r.status < 300) || creditOk;
+ // 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 };
}
← e14bede routes: add NPH (Mac2 + Kamatera) to STRIPE_SECRET_KEY + STR
·
back to Secrets Manager
·
remove ANTHROPIC_API_KEY from registry per standing rule (Ma 56e6b61 →