diff --git a/examples/node9.config.json.example b/examples/node9.config.json.example index 27dd30f..ba40d0e 100644 --- a/examples/node9.config.json.example +++ b/examples/node9.config.json.example @@ -1,36 +1,17 @@ { + "version": "1.0", "settings": { "mode": "standard", - "environment": "production", "autoStartDaemon": true, "enableUndo": true, "enableHookLogDebug": false, - "approvers": { - "native": true, - "browser": false, - "cloud": false, - "terminal": true - } + "approvalTimeoutMs": 30000, + "approvers": { "native": true, "browser": true, "cloud": true, "terminal": true } }, "policy": { "sandboxPaths": ["/tmp/**", "**/sandbox/**", "**/test-results/**"], - "dangerousWords": [ - "drop", - "truncate", - "purge", - "format", - "destroy", - "terminate", - "revoke", - "docker", - "psql", - "rmdir", - "delete", - "alter", - "grant", - "rm" - ], + "dangerousWords": ["mkfs", "shred"], "ignoredTools": [ "list_*", @@ -45,6 +26,9 @@ "webfetch", "websearch", "exitplanmode", + "enterplanmode", + "enterworktree", + "exitworktree", "askuserquestion", "agent", "task*", @@ -58,39 +42,132 @@ "shell": "command", "run_shell_command": "command", "terminal.execute": "command", - "postgres:query": "sql", "mcp__github__*": "command", + "postgres:query": "sql", + "execute_query": "sql", + "query": "sql", + "mcp__postgres__*": "sql", "mcp__redis__*": "query" }, - "rules": [ + "smartRules": [ + { + "name": "allow-readonly-bash", + "tool": "bash", + "conditions": [ + { + "field": "command", + "op": "matches", + "value": "^\\s*(find|grep|rg|cat|head|tail|ls|echo|which|pwd|wc|sort|uniq|diff|du|df|stat|file|type|env|printenv|node --version|npm (list|ls|run (build|test|lint|typecheck|format))|git (log|status|diff|show|branch|remote|fetch|stash list|tag))", + "flags": "i" + }, + { + "field": "command", + "op": "notMatches", + "value": "(&&|\\|\\||;\\s*\\S|\\$\\(|`)", + "flags": "i" + } + ], + "conditionMode": "all", + "verdict": "allow", + "reason": "Read-only or safe bash command — rejected if chained with && || ; $() or backtick" + }, + { + "name": "allow-install-devtools", + "tool": "bash", + "conditions": [ + { + "field": "command", + "op": "matches", + "value": "^\\s*(npm (install|ci|update)|yarn (install|add)|pnpm (install|add))", + "flags": "i" + }, + { + "field": "command", + "op": "notMatches", + "value": "(-g|--global)\\b", + "flags": "i" + } + ], + "conditionMode": "all", + "verdict": "allow", + "reason": "Package install — not destructive (global installs require review)" + }, { - "action": "rm", - "allowPaths": [ - "**/node_modules/**", - "dist/**", - "build/**", - ".next/**", - ".nuxt/**", - "coverage/**", - ".cache/**", - "tmp/**", - "temp/**", - "**/__pycache__/**", - "**/.pytest_cache/**", - "**/*.log", - "**/*.tmp", - ".DS_Store", - "**/yarn.lock", - "**/package-lock.json", - "**/pnpm-lock.yaml" - ] + "name": "review-global-install", + "tool": "bash", + "conditions": [ + { + "field": "command", + "op": "matches", + "value": "\\b(npm|yarn|pnpm)\\b.+(-g|--global)\\b", + "flags": "i" + } + ], + "conditionMode": "all", + "verdict": "review", + "reason": "Global package install modifies the system PATH — requires explicit approval" + }, + { + "name": "review-command-substitution", + "tool": "bash", + "conditions": [ + { + "field": "command", + "op": "matches", + "value": "(\\$\\(|`)", + "flags": "i" + } + ], + "conditionMode": "all", + "verdict": "review", + "reason": "Command contains $() or backtick substitution — can run arbitrary subcommands" + }, + { + "name": "review-secrets-write", + "tool": "*", + "conditions": [ + { + "field": "file_path", + "op": "matches", + "value": "(^|[/\\\\])(\\.env(\\.\\w+)?$|\\.pem$|\\.key$|id_rsa|credentials\\.json|secrets?\\.json)" + }, + { + "field": "path", + "op": "matches", + "value": "(^|[/\\\\])(\\.env(\\.\\w+)?$|\\.pem$|\\.key$|id_rsa|credentials\\.json|secrets?\\.json)" + }, + { + "field": "filename", + "op": "matches", + "value": "(^|[/\\\\])(\\.env(\\.\\w+)?$|\\.pem$|\\.key$|id_rsa|credentials\\.json|secrets?\\.json)" + } + ], + "conditionMode": "any", + "verdict": "review", + "reason": "Writing to secrets or credentials file" } - ] - }, + ], - "environments": { - "production": { "requireApproval": true }, - "development": { "requireApproval": false } + "snapshot": { + "tools": [ + "str_replace_based_edit_tool", + "write_file", + "edit_file", + "create_file", + "edit", + "replace", + "write" + ], + "onlyPaths": [], + "ignorePaths": [ + "**/node_modules/**", + "dist/**", + "build/**", + ".next/**", + "**/*.log", + "**/tmp/**" + ] + } } } diff --git a/src/__tests__/advanced_policy.test.ts b/src/__tests__/advanced_policy.test.ts index ec9814c..f44a5af 100644 --- a/src/__tests__/advanced_policy.test.ts +++ b/src/__tests__/advanced_policy.test.ts @@ -1,13 +1,18 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; -import { evaluatePolicy, _resetConfigCache } from '../core.js'; +import os from 'os'; +import path from 'path'; +import { evaluatePolicy, getConfig, _resetConfigCache } from '../core.js'; -vi.spyOn(fs, 'existsSync').mockReturnValue(false); -vi.spyOn(fs, 'readFileSync'); +const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false); +const readSpy = vi.spyOn(fs, 'readFileSync'); +const homeSpy = vi.spyOn(os, 'homedir').mockReturnValue('/mock/home'); beforeEach(() => { _resetConfigCache(); - vi.mocked(fs.existsSync).mockReturnValue(false); + existsSpy.mockReturnValue(false); + readSpy.mockReturnValue(''); + homeSpy.mockReturnValue('/mock/home'); }); describe('Path-Based Policy (Advanced)', () => { @@ -83,3 +88,375 @@ describe('Path-Based Policy (Advanced)', () => { expect((await evaluatePolicy('Bash', { command: 'r\\m -rf /' })).decision).toBe('review'); }); }); + +// ── allow-readonly-bash smartRule (chaining guard) ──────────────────────────── + +describe('allow-readonly-bash — chained command guard', () => { + const readonlyAllowRule = { + policy: { + dangerousWords: [], + smartRules: [ + { + name: 'allow-readonly-bash', + tool: 'bash', + conditions: [ + { + field: 'command', + op: 'matches', + value: + '^\\s*(cat|grep|ls|find|echo|head|tail|wc|sort|uniq|diff|du|df|stat|which|pwd|env|printenv|node --version|npm (list|ls|run (build|test|lint|typecheck|format))|git (log|status|diff|show|branch|remote|fetch|stash list|tag))', + flags: 'i', + }, + { + field: 'command', + op: 'notMatches', + value: '(&&|\\|\\||;\\s*\\S)', + flags: 'i', + }, + ], + conditionMode: 'all', + verdict: 'allow', + reason: 'Read-only safe command', + }, + ], + }, + }; + + beforeEach(() => { + existsSpy.mockReturnValue(true); + readSpy.mockReturnValue(JSON.stringify(readonlyAllowRule)); + }); + + it('allows a plain cat command', async () => { + expect((await evaluatePolicy('bash', { command: 'cat README.md' })).decision).toBe('allow'); + }); + + it('allows a git log command', async () => { + expect((await evaluatePolicy('bash', { command: 'git log --oneline -10' })).decision).toBe( + 'allow' + ); + }); + + it('does NOT allow cat chained with && rm', async () => { + const r = await evaluatePolicy('bash', { command: 'cat /etc/passwd && rm -rf /' }); + expect(r.decision).not.toBe('allow'); + }); + + it('does NOT allow cat chained with ; rm', async () => { + const r = await evaluatePolicy('bash', { command: 'cat /etc/hosts; rm secrets.txt' }); + expect(r.decision).not.toBe('allow'); + }); + + it('does NOT allow cat chained with || rm', async () => { + const r = await evaluatePolicy('bash', { command: 'cat missing.txt || rm backup.sql' }); + expect(r.decision).not.toBe('allow'); + }); + + it('allows cat piped to grep (pipe-only is safe)', async () => { + expect( + (await evaluatePolicy('bash', { command: 'cat README.md | grep install' })).decision + ).toBe('allow'); + }); +}); + +// ── allow-readonly-bash — $() and backtick substitution bypass ─────────────── + +describe('allow-readonly-bash — command substitution bypass', () => { + const readonlyAllowRule = { + policy: { + dangerousWords: [], + smartRules: [ + { + name: 'allow-readonly-bash', + tool: 'bash', + conditions: [ + { + field: 'command', + op: 'matches', + value: + '^\\s*(cat|grep|ls|find|echo|head|tail|wc|sort|uniq|diff|du|df|stat|which|pwd|env|printenv|node --version|npm (list|ls|run (build|test|lint|typecheck|format))|git (log|status|diff|show|branch|remote|fetch|stash list|tag))', + flags: 'i', + }, + { + field: 'command', + op: 'notMatches', + value: '(&&|\\|\\||;\\s*\\S|\\$\\(|`)', + flags: 'i', + }, + ], + conditionMode: 'all', + verdict: 'allow', + reason: 'Read-only safe command', + }, + { + name: 'review-command-substitution', + tool: 'bash', + conditions: [{ field: 'command', op: 'matches', value: '(\\$\\(|`)', flags: 'i' }], + conditionMode: 'all', + verdict: 'review', + reason: 'Command substitution detected', + }, + ], + }, + }; + + beforeEach(() => { + existsSpy.mockReturnValue(true); + readSpy.mockReturnValue(JSON.stringify(readonlyAllowRule)); + }); + + it('does NOT allow cat with $() substitution', async () => { + const r = await evaluatePolicy('bash', { command: 'cat $(ls /etc)' }); + expect(r.decision).not.toBe('allow'); + }); + + it('does NOT allow echo with backtick substitution', async () => { + const r = await evaluatePolicy('bash', { command: 'echo `id`' }); + expect(r.decision).not.toBe('allow'); + }); + + it('still allows a plain grep without substitution', async () => { + const r = await evaluatePolicy('bash', { command: 'grep -r TODO src/' }); + expect(r.decision).toBe('allow'); + }); +}); + +// ── allow-install-devtools — global flag guard ──────────────────────────────── + +describe('allow-install-devtools — global install guard', () => { + const installRule = { + policy: { + dangerousWords: [], + smartRules: [ + { + name: 'allow-install-devtools', + tool: 'bash', + conditions: [ + { + field: 'command', + op: 'matches', + value: '^\\s*(npm (install|ci|update)|yarn (install|add)|pnpm (install|add))', + flags: 'i', + }, + { + field: 'command', + op: 'notMatches', + value: '(-g|--global)\\b', + flags: 'i', + }, + ], + conditionMode: 'all', + verdict: 'allow', + reason: 'Package install — not destructive', + }, + { + name: 'review-global-install', + tool: 'bash', + conditions: [ + { + field: 'command', + op: 'matches', + value: '\\b(npm|yarn|pnpm)\\b.+(-g|--global)\\b', + flags: 'i', + }, + ], + conditionMode: 'all', + verdict: 'review', + reason: 'Global install requires approval', + }, + ], + }, + }; + + beforeEach(() => { + existsSpy.mockReturnValue(true); + readSpy.mockReturnValue(JSON.stringify(installRule)); + }); + + it('allows a normal npm install', async () => { + expect((await evaluatePolicy('bash', { command: 'npm install lodash' })).decision).toBe( + 'allow' + ); + }); + + it('does NOT allow npm install -g', async () => { + const r = await evaluatePolicy('bash', { command: 'npm install -g typescript' }); + expect(r.decision).not.toBe('allow'); + }); + + it('does NOT allow npm install --global', async () => { + const r = await evaluatePolicy('bash', { command: 'npm install --global typescript' }); + expect(r.decision).not.toBe('allow'); + }); +}); + +// ── review-secrets-write — multi-field matching ─────────────────────────────── + +describe('review-secrets-write — multi-field matching', () => { + const secretsRule = { + policy: { + dangerousWords: [], + smartRules: [ + { + name: 'review-secrets-write', + tool: '*', + conditions: [ + { + field: 'file_path', + op: 'matches', + value: + '(^|[/\\\\])(\\.env(\\.\\w+)?$|\\.pem$|\\.key$|id_rsa|credentials\\.json|secrets?\\.json)', + }, + { + field: 'path', + op: 'matches', + value: + '(^|[/\\\\])(\\.env(\\.\\w+)?$|\\.pem$|\\.key$|id_rsa|credentials\\.json|secrets?\\.json)', + }, + { + field: 'filename', + op: 'matches', + value: + '(^|[/\\\\])(\\.env(\\.\\w+)?$|\\.pem$|\\.key$|id_rsa|credentials\\.json|secrets?\\.json)', + }, + ], + conditionMode: 'any', + verdict: 'review', + reason: 'Writing to secrets or credentials file', + }, + ], + }, + }; + + beforeEach(() => { + existsSpy.mockReturnValue(true); + readSpy.mockReturnValue(JSON.stringify(secretsRule)); + }); + + it('flags write via file_path field', async () => { + const r = await evaluatePolicy('write', { file_path: '/project/.env' }); + expect(r.decision).toBe('review'); + }); + + it('flags write via path field', async () => { + const r = await evaluatePolicy('write', { path: '/project/credentials.json' }); + expect(r.decision).toBe('review'); + }); + + it('flags write via filename field', async () => { + const r = await evaluatePolicy('write', { filename: 'id_rsa' }); + expect(r.decision).toBe('review'); + }); + + it('does NOT flag a normal source file write', async () => { + const r = await evaluatePolicy('write', { file_path: '/project/src/index.ts' }); + expect(r.decision).not.toBe('review'); + }); + + it('does NOT flag a file whose name merely contains .env as a substring', async () => { + const r = await evaluatePolicy('write', { file_path: '/project/notmy.env.bak' }); + expect(r.decision).not.toBe('review'); + }); +}); + +// ── version mismatch handling ───────────────────────────────────────────────── + +describe('tryLoadConfig — version mismatch handling', () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + }); + + it('emits a warning but continues when minor version differs', () => { + const projectPath = path.join(process.cwd(), 'node9.config.json'); + existsSpy.mockImplementation((p) => String(p) === projectPath); + readSpy.mockImplementation((p) => + String(p) === projectPath ? JSON.stringify({ version: '1.99' }) : '' + ); + const cfg = getConfig(); + // Config should still load (best-effort) + expect(cfg).toBeDefined(); + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('⚠️')); + }); + + it('refuses to load config when major version mismatches', () => { + const projectPath = path.join(process.cwd(), 'node9.config.json'); + existsSpy.mockImplementation((p) => String(p) === projectPath); + readSpy.mockImplementation((p) => + String(p) === projectPath ? JSON.stringify({ version: '2.0' }) : '' + ); + const cfg = getConfig(); + // The incompatible config should be skipped — policy stays at defaults + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('❌')); + // No custom policy from that file should leak in + expect(cfg.policy.dangerousWords).not.toContain('__sentinel__'); + }); +}); + +// ── environments merge ──────────────────────────────────────────────────────── + +describe('getConfig — environments layer merge', () => { + it('merges environments from project config', () => { + const projectPath = path.join(process.cwd(), 'node9.config.json'); + existsSpy.mockImplementation((p) => String(p) === projectPath); + readSpy.mockImplementation((p) => + String(p) === projectPath + ? JSON.stringify({ environments: { production: { requireApproval: true } } }) + : '' + ); + const cfg = getConfig(); + expect(cfg.environments['production']?.requireApproval).toBe(true); + }); + + it('project config overrides global config for the same environment', () => { + const projectPath = path.join(process.cwd(), 'node9.config.json'); + const globalPath = path.join('/mock/home', '.node9', 'config.json'); + existsSpy.mockImplementation((p) => [projectPath, globalPath].includes(String(p))); + readSpy.mockImplementation((p) => { + if (String(p) === globalPath) + return JSON.stringify({ environments: { production: { requireApproval: false } } }); + if (String(p) === projectPath) + return JSON.stringify({ environments: { production: { requireApproval: true } } }); + return ''; + }); + const cfg = getConfig(); + // Project layer is applied after global — project value wins + expect(cfg.environments['production']?.requireApproval).toBe(true); + }); + + it('ignores non-boolean requireApproval values (type safety)', () => { + const projectPath = path.join(process.cwd(), 'node9.config.json'); + existsSpy.mockImplementation((p) => String(p) === projectPath); + readSpy.mockImplementation((p) => + String(p) === projectPath + ? JSON.stringify({ environments: { staging: { requireApproval: 'yes' } } }) + : '' + ); + const cfg = getConfig(); + // Should not inject a string — key should be absent + expect(cfg.environments['staging']?.requireApproval).toBeUndefined(); + }); + + it('merges multiple environments independently', () => { + const projectPath = path.join(process.cwd(), 'node9.config.json'); + existsSpy.mockImplementation((p) => String(p) === projectPath); + readSpy.mockImplementation((p) => + String(p) === projectPath + ? JSON.stringify({ + environments: { + production: { requireApproval: true }, + development: { requireApproval: false }, + }, + }) + : '' + ); + const cfg = getConfig(); + expect(cfg.environments['production']?.requireApproval).toBe(true); + expect(cfg.environments['development']?.requireApproval).toBe(false); + }); +}); diff --git a/src/core.ts b/src/core.ts index 517ad77..544862b 100644 --- a/src/core.ts +++ b/src/core.ts @@ -588,7 +588,7 @@ export const DEFAULT_CONFIG: Config = { { name: 'review-git-push', tool: 'bash', - conditions: [{ field: 'command', op: 'matches', value: '^\\s*git push\\b', flags: 'i' }], + conditions: [{ field: 'command', op: 'matches', value: '^\\s*git\\s+push\\b', flags: 'i' }], conditionMode: 'all', verdict: 'review', reason: 'git push sends changes to a shared remote', @@ -1954,6 +1954,7 @@ export function getConfig(): Config { ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths], }, }; + const mergedEnvironments: Record = { ...DEFAULT_CONFIG.environments }; const applyLayer = (source: Record | null) => { if (!source) return; @@ -1984,6 +1985,20 @@ export function getConfig(): Config { if (s.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s.onlyPaths); if (s.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s.ignorePaths); } + + const envs = (source.environments || {}) as Record; + for (const [envName, envConfig] of Object.entries(envs)) { + if (envConfig && typeof envConfig === 'object') { + const ec = envConfig as Record; + mergedEnvironments[envName] = { + ...mergedEnvironments[envName], + // Validate field types before merging — do not blindly spread user input + ...(typeof ec.requireApproval === 'boolean' + ? { requireApproval: ec.requireApproval } + : {}), + }; + } + } }; applyLayer(globalConfig); @@ -2001,7 +2016,7 @@ export function getConfig(): Config { cachedConfig = { settings: mergedSettings, policy: mergedPolicy, - environments: {}, + environments: mergedEnvironments, }; return cachedConfig; @@ -2019,6 +2034,24 @@ function tryLoadConfig(filePath: string): Record | null { ); return null; } + const SUPPORTED_VERSION = '1.0'; + const SUPPORTED_MAJOR = SUPPORTED_VERSION.split('.')[0]; + const fileVersion = (raw as Record)?.version; + if (fileVersion !== undefined) { + const vStr = String(fileVersion); + const fileMajor = vStr.split('.')[0]; + if (fileMajor !== SUPPORTED_MAJOR) { + process.stderr.write( + `\n❌ Node9: Config at ${filePath} has version "${vStr}" — major version is incompatible with this release (expected "${SUPPORTED_VERSION}"). Config will not be loaded.\n\n` + ); + return null; + } else if (vStr !== SUPPORTED_VERSION) { + process.stderr.write( + `\n⚠️ Node9: Config at ${filePath} declares version "${vStr}" — expected "${SUPPORTED_VERSION}". Continuing with best-effort parsing.\n\n` + ); + } + } + const { sanitized, error } = sanitizeConfig(raw); if (error) { process.stderr.write(