Skip to content

Commit 896cd22

Browse files
nawi-25claude
andcommitted
refactor: load shields dynamically in getConfig() — remove config.json mutation
Shields are now applied as a dedicated layer inside getConfig(): readActiveShields() reads ~/.node9/shields.json, maps each name to the in-memory SHIELDS catalog, and appends their smartRules/dangerousWords to the runtime policy. enable/disable now only write shields.json — config.json is never touched. This eliminates the two-file TOCTOU, the merge/unmerge complexity, and the read-modify-write race condition. Shield rules also update automatically when the catalog changes in a new binary release. Deleted: SHIELD_CONFIG_PATH, readRawConfig, writeRawConfig, all merge/unmerge logic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 30dc5a6 commit 896cd22

File tree

2 files changed

+31
-94
lines changed

2 files changed

+31
-94
lines changed

src/cli.ts

Lines changed: 14 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import readline from 'readline';
2424
import fs from 'fs';
2525
import path from 'path';
2626
import os from 'os';
27-
import crypto from 'crypto';
2827
import { createShadowSnapshot, applyUndo, getSnapshotHistory, computeUndoDiff } from './undo';
2928
import {
3029
getShield,
@@ -1414,36 +1413,9 @@ program
14141413
// ---------------------------------------------------------------------------
14151414
// node9 shield — manage pre-packaged security rule templates
14161415
// ---------------------------------------------------------------------------
1417-
1418-
const SHIELD_CONFIG_PATH = path.join(os.homedir(), '.node9', 'config.json');
1419-
1420-
// Returns the parsed config, or null if the file doesn't exist, or exits on parse/IO error.
1421-
// WARNING: no advisory lock — concurrent shield invocations use last-writer-wins semantics.
1422-
function readRawConfig(): Record<string, unknown> | null {
1423-
try {
1424-
const raw = fs.readFileSync(SHIELD_CONFIG_PATH, 'utf-8');
1425-
return JSON.parse(raw) as Record<string, unknown>;
1426-
} catch (err: unknown) {
1427-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; // file doesn't exist yet
1428-
// Parse error or permission error — bail out to avoid overwriting valid config
1429-
console.error(
1430-
chalk.red(
1431-
`\n❌ Cannot read ${SHIELD_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`
1432-
)
1433-
);
1434-
console.error(chalk.red(' Aborting to avoid overwriting your existing config.\n'));
1435-
process.exit(1);
1436-
}
1437-
}
1438-
1439-
function writeRawConfig(config: Record<string, unknown>): void {
1440-
// mkdirSync is idempotent with recursive:true — no TOCTOU concern here
1441-
fs.mkdirSync(path.dirname(SHIELD_CONFIG_PATH), { recursive: true });
1442-
// Random suffix avoids pid collision on concurrent CLI invocations
1443-
const tmp = `${SHIELD_CONFIG_PATH}.${crypto.randomBytes(6).toString('hex')}.tmp`;
1444-
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
1445-
fs.renameSync(tmp, SHIELD_CONFIG_PATH);
1446-
}
1416+
// Shields are applied dynamically at getConfig() load time by reading
1417+
// ~/.node9/shields.json and merging the catalog rules into the runtime policy.
1418+
// enable/disable only update shields.json — config.json is never touched.
14471419

14481420
const shieldCmd = program
14491421
.command('shield')
@@ -1458,41 +1430,20 @@ shieldCmd
14581430
console.error(chalk.red(`\n❌ Unknown shield: "${service}"\n`));
14591431
console.log(`Run ${chalk.cyan('node9 shield list')} to see available shields.\n`);
14601432
process.exit(1);
1461-
return;
14621433
}
1463-
const shield = getShield(name);
1464-
if (!shield) throw new Error(`Shield "${name}" resolved but not found — this is a bug`);
1465-
1466-
const config = readRawConfig() ?? {};
1467-
if (!config.policy || typeof config.policy !== 'object') config.policy = {};
1468-
const policy = config.policy as Record<string, unknown>;
1469-
1470-
// Merge smartRules — deduplicate by name prefix
1471-
const prefix = `shield:${name}:`;
1472-
const existing = Array.isArray(policy.smartRules)
1473-
? (policy.smartRules as Array<{ name?: string }>)
1474-
: [];
1475-
policy.smartRules = [
1476-
...existing.filter((r) => !r.name?.startsWith(prefix)),
1477-
...shield.smartRules,
1478-
];
1479-
1480-
// Merge dangerousWords — deduplicated
1481-
const existingWords = Array.isArray(policy.dangerousWords)
1482-
? (policy.dangerousWords as string[])
1483-
: [];
1484-
policy.dangerousWords = [...new Set([...existingWords, ...shield.dangerousWords])];
1485-
1486-
config.policy = policy;
1487-
writeRawConfig(config);
1434+
const shield = getShield(name!)!;
14881435

14891436
const active = readActiveShields();
1490-
if (!active.includes(name)) writeActiveShields([...active, name]);
1437+
if (active.includes(name!)) {
1438+
console.log(chalk.yellow(`\nℹ️ Shield "${name}" is already active.\n`));
1439+
return;
1440+
}
1441+
writeActiveShields([...active, name!]);
14911442

14921443
console.log(chalk.green(`\n🛡️ Shield "${name}" enabled.`));
1493-
console.log(chalk.gray(` Added ${shield.smartRules.length} smart rules.`));
1444+
console.log(chalk.gray(` ${shield.smartRules.length} smart rules now active.`));
14941445
if (shield.dangerousWords.length > 0)
1495-
console.log(chalk.gray(` Added ${shield.dangerousWords.length} dangerous words.`));
1446+
console.log(chalk.gray(` ${shield.dangerousWords.length} dangerous words now active.`));
14961447
if (name === 'filesystem') {
14971448
console.log(
14981449
chalk.yellow(
@@ -1506,53 +1457,22 @@ shieldCmd
15061457

15071458
shieldCmd
15081459
.command('disable <service>')
1509-
.description('Disable a security shield and remove its rules')
1460+
.description('Disable a security shield')
15101461
.action((service: string) => {
15111462
const name = resolveShieldName(service);
15121463
if (!name) {
15131464
console.error(chalk.red(`\n❌ Unknown shield: "${service}"\n`));
15141465
console.log(`Run ${chalk.cyan('node9 shield list')} to see available shields.\n`);
15151466
process.exit(1);
1516-
return;
15171467
}
1518-
const shield = getShield(name);
1519-
if (!shield) throw new Error(`Shield "${name}" resolved but not found — this is a bug`);
15201468

15211469
const active = readActiveShields();
1522-
if (!active.includes(name)) {
1470+
if (!active.includes(name!)) {
15231471
console.log(chalk.yellow(`\nℹ️ Shield "${name}" is not active.\n`));
15241472
return;
15251473
}
15261474

1527-
const config = readRawConfig() ?? {};
1528-
const remaining = active.filter((s) => s !== name);
1529-
1530-
// Only mutate policy if it already exists — avoid creating an empty policy object
1531-
if (config.policy && typeof config.policy === 'object') {
1532-
const policy = config.policy as Record<string, unknown>;
1533-
1534-
// Remove this shield's smartRules
1535-
const prefix = `shield:${name}:`;
1536-
const rules = Array.isArray(policy.smartRules)
1537-
? (policy.smartRules as Array<{ name?: string }>)
1538-
: [];
1539-
policy.smartRules = rules.filter((r) => !r.name?.startsWith(prefix));
1540-
1541-
// Remove dangerousWords, protecting words still needed by other active shields
1542-
const protectedWords = new Set(remaining.flatMap((s) => getShield(s)?.dangerousWords ?? []));
1543-
const shieldWords = new Set(shield.dangerousWords);
1544-
const existingWords = Array.isArray(policy.dangerousWords)
1545-
? (policy.dangerousWords as string[])
1546-
: [];
1547-
policy.dangerousWords = existingWords.filter(
1548-
(w) => !shieldWords.has(w) || protectedWords.has(w)
1549-
);
1550-
1551-
config.policy = policy;
1552-
writeRawConfig(config);
1553-
}
1554-
1555-
writeActiveShields(remaining);
1475+
writeActiveShields(active.filter((s) => s !== name));
15561476

15571477
console.log(chalk.green(`\n🛡️ Shield "${name}" disabled.\n`));
15581478
});

src/core.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { parse } from 'sh-syntax';
99
import { askNativePopup, sendDesktopNotification } from './ui/native';
1010
import { computeRiskMetadata, RiskMetadata } from './context-sniper';
1111
import { sanitizeConfig } from './config-schema';
12+
import { readActiveShields, getShield } from './shields';
1213

1314
// ── Feature file paths ────────────────────────────────────────────────────────
1415
const PAUSED_FILE = path.join(os.homedir(), '.node9', 'PAUSED');
@@ -2004,6 +2005,22 @@ export function getConfig(): Config {
20042005
applyLayer(globalConfig);
20052006
applyLayer(projectConfig);
20062007

2008+
// ── Shield layer ──────────────────────────────────────────────────────────
2009+
// Shields are applied after user config so they cannot be overridden locally.
2010+
// Rules are sourced from the in-memory catalog, not from config.json — so
2011+
// enabling a shield never mutates the user's config file.
2012+
for (const shieldName of readActiveShields()) {
2013+
const shield = getShield(shieldName);
2014+
if (!shield) continue;
2015+
// Deduplicate smartRules by name — prevents duplicates if the user also
2016+
// has the same rule name in their config (shouldn't happen, but be safe).
2017+
const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
2018+
for (const rule of shield.smartRules) {
2019+
if (!existingRuleNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
2020+
}
2021+
for (const word of shield.dangerousWords) mergedPolicy.dangerousWords.push(word);
2022+
}
2023+
20072024
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE as string;
20082025

20092026
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];

0 commit comments

Comments
 (0)