@node9/proxy 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ While others try to _guess_ if a prompt is malicious (Semantic Security), Node9
16
16
  **AIs are literal.** When you ask an agent to "Fix my disk space," it might decide to run `docker system prune -af`.
17
17
 
18
18
  <p align="center">
19
- <img src="https://github.com/user-attachments/assets/0e45e843-4cf7-408e-95ce-23fb09525ee4" width="100%">
19
+ <img src="https://github.com/user-attachments/assets/afae9caa-0605-4cac-929a-c14198383169" width="100%">
20
20
  </p>
21
21
 
22
22
  **With Node9, the interaction looks like this:**
@@ -130,6 +130,7 @@ Rules are **merged additive**—you cannot "un-danger" a word locally if it was
130
130
  "settings": {
131
131
  "mode": "standard",
132
132
  "enableUndo": true,
133
+ "approvalTimeoutMs": 30000,
133
134
  "approvers": {
134
135
  "native": true,
135
136
  "browser": true,
@@ -144,14 +145,94 @@ Rules are **merged additive**—you cannot "un-danger" a word locally if it was
144
145
  "toolInspection": {
145
146
  "bash": "command",
146
147
  "postgres:query": "sql"
147
- }
148
+ },
149
+ "rules": [
150
+ { "action": "rm", "allowPaths": ["**/node_modules/**", "dist/**"] },
151
+ { "action": "push", "blockPaths": ["**"] }
152
+ ],
153
+ "smartRules": [
154
+ {
155
+ "name": "no-delete-without-where",
156
+ "tool": "*",
157
+ "conditions": [
158
+ { "field": "sql", "op": "matches", "value": "^(DELETE|UPDATE)\\s", "flags": "i" },
159
+ { "field": "sql", "op": "notMatches", "value": "\\bWHERE\\b", "flags": "i" }
160
+ ],
161
+ "verdict": "review",
162
+ "reason": "DELETE/UPDATE without WHERE — would affect every row"
163
+ }
164
+ ]
148
165
  }
149
166
  }
150
167
  ```
151
168
 
152
- ---
169
+ ### ⚙️ `settings` options
153
170
 
154
- ---
171
+ | Key | Default | Description |
172
+ | :------------------- | :----------- | :----------------------------------------------------------- |
173
+ | `mode` | `"standard"` | `standard` \| `strict` \| `audit` |
174
+ | `enableUndo` | `true` | Take git snapshots before every AI file edit |
175
+ | `approvalTimeoutMs` | `0` | Auto-deny after N ms if no human responds (0 = wait forever) |
176
+ | `approvers.native` | `true` | OS-native popup |
177
+ | `approvers.browser` | `true` | Browser dashboard (`node9 daemon`) |
178
+ | `approvers.cloud` | `true` | Slack / SaaS approval |
179
+ | `approvers.terminal` | `true` | `[Y/n]` prompt in terminal |
180
+
181
+ ### 🧠 Smart Rules
182
+
183
+ Smart rules match on **raw tool arguments** using structured conditions — more powerful than `dangerousWords` or `rules`, which only see extracted tokens.
184
+
185
+ ```json
186
+ {
187
+ "name": "curl-pipe-to-shell",
188
+ "tool": "bash",
189
+ "conditions": [{ "field": "command", "op": "matches", "value": "curl.+\\|.*(bash|sh)" }],
190
+ "verdict": "block",
191
+ "reason": "curl piped to shell — remote code execution risk"
192
+ }
193
+ ```
194
+
195
+ **Fields:**
196
+
197
+ | Field | Description |
198
+ | :-------------- | :----------------------------------------------------------------------------------- |
199
+ | `tool` | Tool name or glob (`"bash"`, `"mcp__postgres__*"`, `"*"`) |
200
+ | `conditions` | Array of conditions evaluated against the raw args object |
201
+ | `conditionMode` | `"all"` (AND, default) or `"any"` (OR) |
202
+ | `verdict` | `"review"` (approval prompt) \| `"block"` (hard deny) \| `"allow"` (skip all checks) |
203
+ | `reason` | Human-readable explanation shown in the approval prompt and audit log |
204
+
205
+ **Condition operators:**
206
+
207
+ | `op` | Meaning |
208
+ | :------------ | :------------------------------------------------------------------ |
209
+ | `matches` | Field value matches regex (`value` = pattern, `flags` = e.g. `"i"`) |
210
+ | `notMatches` | Field value does not match regex |
211
+ | `contains` | Field value contains substring |
212
+ | `notContains` | Field value does not contain substring |
213
+ | `exists` | Field is present and non-empty |
214
+ | `notExists` | Field is absent or empty |
215
+
216
+ The `field` key supports dot-notation for nested args: `"params.query.sql"`.
217
+
218
+ **Built-in default smart rule** (always active, no config needed):
219
+
220
+ ```json
221
+ {
222
+ "name": "no-delete-without-where",
223
+ "tool": "*",
224
+ "conditions": [
225
+ { "field": "sql", "op": "matches", "value": "^(DELETE|UPDATE)\\s", "flags": "i" },
226
+ { "field": "sql", "op": "notMatches", "value": "\\bWHERE\\b", "flags": "i" }
227
+ ],
228
+ "verdict": "review",
229
+ "reason": "DELETE/UPDATE without WHERE clause — would affect every row in the table"
230
+ }
231
+ ```
232
+
233
+ Use `node9 explain <tool> <args>` to dry-run any tool call and see exactly which smart rule (or other policy tier) would trigger.
234
+
235
+ ## <<<<<<< Updated upstream
155
236
 
156
237
  ## 🖥️ CLI Reference
157
238
 
@@ -212,6 +293,107 @@ Verdict: BLOCK (dangerous word: rm -rf)
212
293
 
213
294
  ---
214
295
 
296
+ ## 🖥️ CLI Reference
297
+
298
+ | Command | Description |
299
+ | :---------------------------- | :------------------------------------------------------------------------------------ |
300
+ | `node9 setup` | Interactive menu — detects installed agents and wires hooks for you |
301
+ | `node9 addto <agent>` | Wire hooks for a specific agent (`claude`, `gemini`, `cursor`) |
302
+ | `node9 init` | Create default `~/.node9/config.json` |
303
+ | `node9 status` | Show current protection status and active rules |
304
+ | `node9 doctor` | Health check — verifies binaries, config, credentials, and all agent hooks |
305
+ | `node9 explain <tool> [args]` | Trace the policy waterfall for a given tool call (dry-run, no approval prompt) |
306
+ | `node9 undo [--steps N]` | Revert the last N AI file edits using shadow Git snapshots |
307
+ | `node9 check` | Called by agent hooks; evaluates a pending tool call and exits 0 (allow) or 1 (block) |
308
+
309
+ ### `node9 doctor`
310
+
311
+ Runs a full self-test and exits 1 if any required check fails:
312
+
313
+ ```
314
+ Node9 Doctor v1.2.0
315
+ ────────────────────────────────────────
316
+ Binaries
317
+ ✅ Node.js v20.11.0
318
+ ✅ git version 2.43.0
319
+
320
+ Configuration
321
+ ✅ ~/.node9/config.json found and valid
322
+ ✅ ~/.node9/credentials.json — cloud credentials found
323
+
324
+ Agent Hooks
325
+ ✅ Claude Code — PreToolUse hook active
326
+ ⚠️ Gemini CLI — not configured (optional)
327
+ ⚠️ Cursor — not configured (optional)
328
+
329
+ ────────────────────────────────────────
330
+ All checks passed ✅
331
+ ```
332
+
333
+ ### `node9 explain`
334
+
335
+ ## =======
336
+
337
+ ## 🖥️ CLI Reference
338
+
339
+ | Command | Description |
340
+ | :---------------------------- | :------------------------------------------------------------------------------------ |
341
+ | `node9 setup` | Interactive menu — detects installed agents and wires hooks for you |
342
+ | `node9 addto <agent>` | Wire hooks for a specific agent (`claude`, `gemini`, `cursor`) |
343
+ | `node9 init` | Create default `~/.node9/config.json` |
344
+ | `node9 status` | Show current protection status and active rules |
345
+ | `node9 doctor` | Health check — verifies binaries, config, credentials, and all agent hooks |
346
+ | `node9 explain <tool> [args]` | Trace the policy waterfall for a given tool call (dry-run, no approval prompt) |
347
+ | `node9 undo [--steps N]` | Revert the last N AI file edits using shadow Git snapshots |
348
+ | `node9 check` | Called by agent hooks; evaluates a pending tool call and exits 0 (allow) or 1 (block) |
349
+
350
+ ### `node9 doctor`
351
+
352
+ Runs a full self-test and exits 1 if any required check fails:
353
+
354
+ ```
355
+ Node9 Doctor v1.2.0
356
+ ────────────────────────────────────────
357
+ Binaries
358
+ ✅ Node.js v20.11.0
359
+ ✅ git version 2.43.0
360
+
361
+ Configuration
362
+ ✅ ~/.node9/config.json found and valid
363
+ ✅ ~/.node9/credentials.json — cloud credentials found
364
+
365
+ Agent Hooks
366
+ ✅ Claude Code — PreToolUse hook active
367
+ ⚠️ Gemini CLI — not configured (optional)
368
+ ⚠️ Cursor — not configured (optional)
369
+
370
+ ────────────────────────────────────────
371
+ All checks passed ✅
372
+ ```
373
+
374
+ ### `node9 explain`
375
+
376
+ > > > > > > > Stashed changes
377
+ > > > > > > > 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:
378
+
379
+ ```bash
380
+ node9 explain bash '{"command":"rm -rf /tmp/build"}'
381
+ ```
382
+
383
+ ```
384
+ Policy Waterfall for: bash
385
+ ──────────────────────────────────────────────
386
+ Tier 1 · Cloud Org Policy SKIP (no org policy loaded)
387
+ Tier 2 · Dangerous Words BLOCK ← matched "rm -rf"
388
+ Tier 3 · Path Block –
389
+ Tier 4 · Inline Exec –
390
+ Tier 5 · Rule Match –
391
+ ──────────────────────────────────────────────
392
+ Verdict: BLOCK (dangerous word: rm -rf)
393
+ ```
394
+
395
+ ---
396
+
215
397
  ## 🔧 Troubleshooting
216
398
 
217
399
  **`node9 check` exits immediately / Claude is never blocked**
@@ -242,4 +424,4 @@ A corporate policy has locked this action. You must click the "Approve" button i
242
424
  ## 🏢 Enterprise & Compliance
243
425
 
244
426
  Node9 Pro provides **Governance Locking**, **SAML/SSO**, and **VPC Deployment**.
245
- Visit [node9.ai](https://node9.ai
427
+ Visit [node9.ai](https://node9.ai)
package/dist/cli.js CHANGED
@@ -344,6 +344,43 @@ function getNestedValue(obj, path6) {
344
344
  if (!obj || typeof obj !== "object") return null;
345
345
  return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
346
346
  }
347
+ function evaluateSmartConditions(args, rule) {
348
+ if (!rule.conditions || rule.conditions.length === 0) return true;
349
+ const mode = rule.conditionMode ?? "all";
350
+ const results = rule.conditions.map((cond) => {
351
+ const rawVal = getNestedValue(args, cond.field);
352
+ const val = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
353
+ switch (cond.op) {
354
+ case "exists":
355
+ return val !== null && val !== "";
356
+ case "notExists":
357
+ return val === null || val === "";
358
+ case "contains":
359
+ return val !== null && cond.value ? val.includes(cond.value) : false;
360
+ case "notContains":
361
+ return val !== null && cond.value ? !val.includes(cond.value) : true;
362
+ case "matches": {
363
+ if (val === null || !cond.value) return false;
364
+ try {
365
+ return new RegExp(cond.value, cond.flags ?? "").test(val);
366
+ } catch {
367
+ return false;
368
+ }
369
+ }
370
+ case "notMatches": {
371
+ if (val === null || !cond.value) return true;
372
+ try {
373
+ return !new RegExp(cond.value, cond.flags ?? "").test(val);
374
+ } catch {
375
+ return true;
376
+ }
377
+ }
378
+ default:
379
+ return false;
380
+ }
381
+ });
382
+ return mode === "any" ? results.some((r) => r) : results.every((r) => r);
383
+ }
347
384
  function extractShellCommand(toolName, args, toolInspection) {
348
385
  const patterns = Object.keys(toolInspection);
349
386
  const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
@@ -360,15 +397,6 @@ function isSqlTool(toolName, toolInspection) {
360
397
  return fieldName === "sql" || fieldName === "query";
361
398
  }
362
399
  var SQL_DML_KEYWORDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "merge", "upsert"]);
363
- function checkDangerousSql(sql) {
364
- const norm = sql.replace(/\s+/g, " ").trim().toLowerCase();
365
- const hasWhere = /\bwhere\b/.test(norm);
366
- if (/^delete\s+from\s+\S+/.test(norm) && !hasWhere)
367
- return "DELETE without WHERE \u2014 full table wipe";
368
- if (/^update\s+\S+\s+set\s+/.test(norm) && !hasWhere)
369
- return "UPDATE without WHERE \u2014 updates every row";
370
- return null;
371
- }
372
400
  async function analyzeShellCommand(command) {
373
401
  const actions = [];
374
402
  const paths = [];
@@ -468,6 +496,8 @@ var DEFAULT_CONFIG = {
468
496
  enableUndo: true,
469
497
  // 🔥 ALWAYS TRUE BY DEFAULT for the safety net
470
498
  enableHookLogDebug: false,
499
+ approvalTimeoutMs: 0,
500
+ // 0 = disabled; set e.g. 30000 for 30-second auto-deny
471
501
  approvers: { native: true, browser: true, cloud: true, terminal: true }
472
502
  },
473
503
  policy: {
@@ -516,6 +546,19 @@ var DEFAULT_CONFIG = {
516
546
  ".DS_Store"
517
547
  ]
518
548
  }
549
+ ],
550
+ smartRules: [
551
+ {
552
+ name: "no-delete-without-where",
553
+ tool: "*",
554
+ conditions: [
555
+ { field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
556
+ { field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
557
+ ],
558
+ conditionMode: "all",
559
+ verdict: "review",
560
+ reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
561
+ }
519
562
  ]
520
563
  },
521
564
  environments: {}
@@ -562,6 +605,19 @@ function getInternalToken() {
562
605
  async function evaluatePolicy(toolName, args, agent) {
563
606
  const config = getConfig();
564
607
  if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
608
+ if (config.policy.smartRules.length > 0) {
609
+ const matchedRule = config.policy.smartRules.find(
610
+ (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
611
+ );
612
+ if (matchedRule) {
613
+ if (matchedRule.verdict === "allow") return { decision: "allow" };
614
+ return {
615
+ decision: matchedRule.verdict,
616
+ blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
617
+ reason: matchedRule.reason
618
+ };
619
+ }
620
+ }
565
621
  let allTokens = [];
566
622
  let actionTokens = [];
567
623
  let pathTokens = [];
@@ -576,8 +632,6 @@ async function evaluatePolicy(toolName, args, agent) {
576
632
  return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
577
633
  }
578
634
  if (isSqlTool(toolName, config.policy.toolInspection)) {
579
- const sqlDanger = checkDangerousSql(shellCommand);
580
- if (sqlDanger) return { decision: "review", blockedByLabel: `SQL Safety: ${sqlDanger}` };
581
635
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
582
636
  actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
583
637
  }
@@ -707,6 +761,44 @@ async function explainPolicy(toolName, args) {
707
761
  outcome: "checked",
708
762
  detail: `"${toolName}" not in ignoredTools list`
709
763
  });
764
+ if (config.policy.smartRules.length > 0) {
765
+ const matchedRule = config.policy.smartRules.find(
766
+ (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
767
+ );
768
+ if (matchedRule) {
769
+ const label = `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`;
770
+ if (matchedRule.verdict === "allow") {
771
+ steps.push({
772
+ name: "Smart rules",
773
+ outcome: "allow",
774
+ detail: `${label} \u2192 allow`,
775
+ isFinal: true
776
+ });
777
+ return { tool: toolName, args, waterfall, steps, decision: "allow" };
778
+ }
779
+ steps.push({
780
+ name: "Smart rules",
781
+ outcome: matchedRule.verdict,
782
+ detail: `${label} \u2192 ${matchedRule.verdict}${matchedRule.reason ? `: ${matchedRule.reason}` : ""}`,
783
+ isFinal: true
784
+ });
785
+ return {
786
+ tool: toolName,
787
+ args,
788
+ waterfall,
789
+ steps,
790
+ decision: matchedRule.verdict,
791
+ blockedByLabel: label
792
+ };
793
+ }
794
+ steps.push({
795
+ name: "Smart rules",
796
+ outcome: "checked",
797
+ detail: `No smart rule matched "${toolName}"`
798
+ });
799
+ } else {
800
+ steps.push({ name: "Smart rules", outcome: "skip", detail: "No smart rules configured" });
801
+ }
710
802
  let allTokens = [];
711
803
  let actionTokens = [];
712
804
  let pathTokens = [];
@@ -747,29 +839,12 @@ async function explainPolicy(toolName, args) {
747
839
  detail: "No inline execution pattern detected"
748
840
  });
749
841
  if (isSqlTool(toolName, config.policy.toolInspection)) {
750
- const sqlDanger = checkDangerousSql(shellCommand);
751
- if (sqlDanger) {
752
- steps.push({
753
- name: "SQL safety",
754
- outcome: "review",
755
- detail: sqlDanger,
756
- isFinal: true
757
- });
758
- return {
759
- tool: toolName,
760
- args,
761
- waterfall,
762
- steps,
763
- decision: "review",
764
- blockedByLabel: `SQL Safety: ${sqlDanger}`
765
- };
766
- }
767
842
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
768
843
  actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
769
844
  steps.push({
770
- name: "SQL safety",
845
+ name: "SQL token stripping",
771
846
  outcome: "checked",
772
- detail: "DELETE/UPDATE have a WHERE clause \u2014 scoped mutation, safe"
847
+ detail: "DML keywords stripped from tokens (SQL safety handled by smart rules)"
773
848
  });
774
849
  }
775
850
  } else {
@@ -1070,6 +1145,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1070
1145
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
1071
1146
  return { approved: true, checkedBy: "local-policy" };
1072
1147
  }
1148
+ if (policyResult.decision === "block") {
1149
+ if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
1150
+ return {
1151
+ approved: false,
1152
+ reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
1153
+ blockedBy: "local-config",
1154
+ blockedByLabel: policyResult.blockedByLabel
1155
+ };
1156
+ }
1073
1157
  explainableLabel = policyResult.blockedByLabel || "Local Config";
1074
1158
  const persistent = getPersistentDecision(toolName);
1075
1159
  if (persistent === "allow") {
@@ -1138,6 +1222,25 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1138
1222
  const abortController = new AbortController();
1139
1223
  const { signal } = abortController;
1140
1224
  const racePromises = [];
1225
+ const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0;
1226
+ if (approvalTimeoutMs > 0) {
1227
+ racePromises.push(
1228
+ new Promise((resolve, reject) => {
1229
+ const timer = setTimeout(() => {
1230
+ resolve({
1231
+ approved: false,
1232
+ reason: `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy.`,
1233
+ blockedBy: "timeout",
1234
+ blockedByLabel: "Approval Timeout"
1235
+ });
1236
+ }, approvalTimeoutMs);
1237
+ signal.addEventListener("abort", () => {
1238
+ clearTimeout(timer);
1239
+ reject(new Error("Aborted"));
1240
+ });
1241
+ })
1242
+ );
1243
+ }
1141
1244
  let viewerId = null;
1142
1245
  const internalToken = getInternalToken();
1143
1246
  if (cloudEnforced && cloudRequestId) {
@@ -1338,7 +1441,8 @@ function getConfig() {
1338
1441
  dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
1339
1442
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
1340
1443
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
1341
- rules: [...DEFAULT_CONFIG.policy.rules]
1444
+ rules: [...DEFAULT_CONFIG.policy.rules],
1445
+ smartRules: [...DEFAULT_CONFIG.policy.smartRules]
1342
1446
  };
1343
1447
  const applyLayer = (source) => {
1344
1448
  if (!source) return;
@@ -1357,6 +1461,7 @@ function getConfig() {
1357
1461
  if (p.toolInspection)
1358
1462
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1359
1463
  if (p.rules) mergedPolicy.rules.push(...p.rules);
1464
+ if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1360
1465
  };
1361
1466
  applyLayer(globalConfig);
1362
1467
  applyLayer(projectConfig);
@@ -3861,6 +3966,74 @@ program.command("init").description("Create ~/.node9/config.json with default po
3861
3966
  import_chalk5.default.gray(` Undo Engine is ENABLED by default. Use 'node9 undo' to revert AI changes.`)
3862
3967
  );
3863
3968
  });
3969
+ function formatRelativeTime(timestamp) {
3970
+ const diff = Date.now() - new Date(timestamp).getTime();
3971
+ const sec = Math.floor(diff / 1e3);
3972
+ if (sec < 60) return `${sec}s ago`;
3973
+ const min = Math.floor(sec / 60);
3974
+ if (min < 60) return `${min}m ago`;
3975
+ const hrs = Math.floor(min / 60);
3976
+ if (hrs < 24) return `${hrs}h ago`;
3977
+ return new Date(timestamp).toLocaleDateString();
3978
+ }
3979
+ program.command("audit").description("View local execution audit log").option("--tail <n>", "Number of entries to show", "20").option("--tool <pattern>", "Filter by tool name (substring match)").option("--deny", "Show only denied actions").option("--json", "Output raw JSON").action((options) => {
3980
+ const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "audit.log");
3981
+ if (!import_fs5.default.existsSync(logPath)) {
3982
+ console.log(
3983
+ import_chalk5.default.yellow("No audit logs found. Run node9 with an agent to generate entries.")
3984
+ );
3985
+ return;
3986
+ }
3987
+ const raw = import_fs5.default.readFileSync(logPath, "utf-8");
3988
+ const lines = raw.split("\n").filter((l) => l.trim() !== "");
3989
+ let entries = lines.flatMap((line) => {
3990
+ try {
3991
+ return [JSON.parse(line)];
3992
+ } catch {
3993
+ return [];
3994
+ }
3995
+ });
3996
+ entries = entries.map((e) => ({
3997
+ ...e,
3998
+ decision: String(e.decision).startsWith("allow") ? "allow" : "deny"
3999
+ }));
4000
+ if (options.tool) entries = entries.filter((e) => String(e.tool).includes(options.tool));
4001
+ if (options.deny) entries = entries.filter((e) => e.decision === "deny");
4002
+ const limit = Math.max(1, parseInt(options.tail, 10) || 20);
4003
+ entries = entries.slice(-limit);
4004
+ if (options.json) {
4005
+ console.log(JSON.stringify(entries, null, 2));
4006
+ return;
4007
+ }
4008
+ if (entries.length === 0) {
4009
+ console.log(import_chalk5.default.yellow("No matching audit entries."));
4010
+ return;
4011
+ }
4012
+ console.log(
4013
+ `
4014
+ ${import_chalk5.default.bold("Node9 Audit Log")} ${import_chalk5.default.dim(`(${entries.length} entries)`)}`
4015
+ );
4016
+ console.log(import_chalk5.default.dim(" " + "\u2500".repeat(65)));
4017
+ console.log(
4018
+ ` ${"Time".padEnd(12)} ${"Tool".padEnd(18)} ${"Result".padEnd(10)} ${"By".padEnd(15)} Agent`
4019
+ );
4020
+ console.log(import_chalk5.default.dim(" " + "\u2500".repeat(65)));
4021
+ for (const e of entries) {
4022
+ const time = formatRelativeTime(String(e.ts)).padEnd(12);
4023
+ const tool = String(e.tool).slice(0, 17).padEnd(18);
4024
+ const result = e.decision === "allow" ? import_chalk5.default.green("ALLOW".padEnd(10)) : import_chalk5.default.red("DENY".padEnd(10));
4025
+ const checker = String(e.checkedBy || "unknown").slice(0, 14).padEnd(15);
4026
+ const agent = String(e.agent || "unknown");
4027
+ console.log(` ${time} ${tool} ${result} ${checker} ${agent}`);
4028
+ }
4029
+ const allowed = entries.filter((e) => e.decision === "allow").length;
4030
+ const denied = entries.filter((e) => e.decision === "deny").length;
4031
+ console.log(import_chalk5.default.dim(" " + "\u2500".repeat(65)));
4032
+ console.log(
4033
+ ` ${entries.length} entries | ${import_chalk5.default.green(allowed + " allowed")} | ${import_chalk5.default.red(denied + " denied")}
4034
+ `
4035
+ );
4036
+ });
3864
4037
  program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
3865
4038
  const creds = getCredentials();
3866
4039
  const daemonRunning = isDaemonRunning();
package/dist/cli.mjs CHANGED
@@ -321,6 +321,43 @@ function getNestedValue(obj, path6) {
321
321
  if (!obj || typeof obj !== "object") return null;
322
322
  return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
323
323
  }
324
+ function evaluateSmartConditions(args, rule) {
325
+ if (!rule.conditions || rule.conditions.length === 0) return true;
326
+ const mode = rule.conditionMode ?? "all";
327
+ const results = rule.conditions.map((cond) => {
328
+ const rawVal = getNestedValue(args, cond.field);
329
+ const val = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
330
+ switch (cond.op) {
331
+ case "exists":
332
+ return val !== null && val !== "";
333
+ case "notExists":
334
+ return val === null || val === "";
335
+ case "contains":
336
+ return val !== null && cond.value ? val.includes(cond.value) : false;
337
+ case "notContains":
338
+ return val !== null && cond.value ? !val.includes(cond.value) : true;
339
+ case "matches": {
340
+ if (val === null || !cond.value) return false;
341
+ try {
342
+ return new RegExp(cond.value, cond.flags ?? "").test(val);
343
+ } catch {
344
+ return false;
345
+ }
346
+ }
347
+ case "notMatches": {
348
+ if (val === null || !cond.value) return true;
349
+ try {
350
+ return !new RegExp(cond.value, cond.flags ?? "").test(val);
351
+ } catch {
352
+ return true;
353
+ }
354
+ }
355
+ default:
356
+ return false;
357
+ }
358
+ });
359
+ return mode === "any" ? results.some((r) => r) : results.every((r) => r);
360
+ }
324
361
  function extractShellCommand(toolName, args, toolInspection) {
325
362
  const patterns = Object.keys(toolInspection);
326
363
  const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
@@ -337,15 +374,6 @@ function isSqlTool(toolName, toolInspection) {
337
374
  return fieldName === "sql" || fieldName === "query";
338
375
  }
339
376
  var SQL_DML_KEYWORDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "merge", "upsert"]);
340
- function checkDangerousSql(sql) {
341
- const norm = sql.replace(/\s+/g, " ").trim().toLowerCase();
342
- const hasWhere = /\bwhere\b/.test(norm);
343
- if (/^delete\s+from\s+\S+/.test(norm) && !hasWhere)
344
- return "DELETE without WHERE \u2014 full table wipe";
345
- if (/^update\s+\S+\s+set\s+/.test(norm) && !hasWhere)
346
- return "UPDATE without WHERE \u2014 updates every row";
347
- return null;
348
- }
349
377
  async function analyzeShellCommand(command) {
350
378
  const actions = [];
351
379
  const paths = [];
@@ -445,6 +473,8 @@ var DEFAULT_CONFIG = {
445
473
  enableUndo: true,
446
474
  // 🔥 ALWAYS TRUE BY DEFAULT for the safety net
447
475
  enableHookLogDebug: false,
476
+ approvalTimeoutMs: 0,
477
+ // 0 = disabled; set e.g. 30000 for 30-second auto-deny
448
478
  approvers: { native: true, browser: true, cloud: true, terminal: true }
449
479
  },
450
480
  policy: {
@@ -493,6 +523,19 @@ var DEFAULT_CONFIG = {
493
523
  ".DS_Store"
494
524
  ]
495
525
  }
526
+ ],
527
+ smartRules: [
528
+ {
529
+ name: "no-delete-without-where",
530
+ tool: "*",
531
+ conditions: [
532
+ { field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
533
+ { field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
534
+ ],
535
+ conditionMode: "all",
536
+ verdict: "review",
537
+ reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
538
+ }
496
539
  ]
497
540
  },
498
541
  environments: {}
@@ -539,6 +582,19 @@ function getInternalToken() {
539
582
  async function evaluatePolicy(toolName, args, agent) {
540
583
  const config = getConfig();
541
584
  if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
585
+ if (config.policy.smartRules.length > 0) {
586
+ const matchedRule = config.policy.smartRules.find(
587
+ (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
588
+ );
589
+ if (matchedRule) {
590
+ if (matchedRule.verdict === "allow") return { decision: "allow" };
591
+ return {
592
+ decision: matchedRule.verdict,
593
+ blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
594
+ reason: matchedRule.reason
595
+ };
596
+ }
597
+ }
542
598
  let allTokens = [];
543
599
  let actionTokens = [];
544
600
  let pathTokens = [];
@@ -553,8 +609,6 @@ async function evaluatePolicy(toolName, args, agent) {
553
609
  return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
554
610
  }
555
611
  if (isSqlTool(toolName, config.policy.toolInspection)) {
556
- const sqlDanger = checkDangerousSql(shellCommand);
557
- if (sqlDanger) return { decision: "review", blockedByLabel: `SQL Safety: ${sqlDanger}` };
558
612
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
559
613
  actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
560
614
  }
@@ -684,6 +738,44 @@ async function explainPolicy(toolName, args) {
684
738
  outcome: "checked",
685
739
  detail: `"${toolName}" not in ignoredTools list`
686
740
  });
741
+ if (config.policy.smartRules.length > 0) {
742
+ const matchedRule = config.policy.smartRules.find(
743
+ (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
744
+ );
745
+ if (matchedRule) {
746
+ const label = `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`;
747
+ if (matchedRule.verdict === "allow") {
748
+ steps.push({
749
+ name: "Smart rules",
750
+ outcome: "allow",
751
+ detail: `${label} \u2192 allow`,
752
+ isFinal: true
753
+ });
754
+ return { tool: toolName, args, waterfall, steps, decision: "allow" };
755
+ }
756
+ steps.push({
757
+ name: "Smart rules",
758
+ outcome: matchedRule.verdict,
759
+ detail: `${label} \u2192 ${matchedRule.verdict}${matchedRule.reason ? `: ${matchedRule.reason}` : ""}`,
760
+ isFinal: true
761
+ });
762
+ return {
763
+ tool: toolName,
764
+ args,
765
+ waterfall,
766
+ steps,
767
+ decision: matchedRule.verdict,
768
+ blockedByLabel: label
769
+ };
770
+ }
771
+ steps.push({
772
+ name: "Smart rules",
773
+ outcome: "checked",
774
+ detail: `No smart rule matched "${toolName}"`
775
+ });
776
+ } else {
777
+ steps.push({ name: "Smart rules", outcome: "skip", detail: "No smart rules configured" });
778
+ }
687
779
  let allTokens = [];
688
780
  let actionTokens = [];
689
781
  let pathTokens = [];
@@ -724,29 +816,12 @@ async function explainPolicy(toolName, args) {
724
816
  detail: "No inline execution pattern detected"
725
817
  });
726
818
  if (isSqlTool(toolName, config.policy.toolInspection)) {
727
- const sqlDanger = checkDangerousSql(shellCommand);
728
- if (sqlDanger) {
729
- steps.push({
730
- name: "SQL safety",
731
- outcome: "review",
732
- detail: sqlDanger,
733
- isFinal: true
734
- });
735
- return {
736
- tool: toolName,
737
- args,
738
- waterfall,
739
- steps,
740
- decision: "review",
741
- blockedByLabel: `SQL Safety: ${sqlDanger}`
742
- };
743
- }
744
819
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
745
820
  actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
746
821
  steps.push({
747
- name: "SQL safety",
822
+ name: "SQL token stripping",
748
823
  outcome: "checked",
749
- detail: "DELETE/UPDATE have a WHERE clause \u2014 scoped mutation, safe"
824
+ detail: "DML keywords stripped from tokens (SQL safety handled by smart rules)"
750
825
  });
751
826
  }
752
827
  } else {
@@ -1047,6 +1122,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1047
1122
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
1048
1123
  return { approved: true, checkedBy: "local-policy" };
1049
1124
  }
1125
+ if (policyResult.decision === "block") {
1126
+ if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
1127
+ return {
1128
+ approved: false,
1129
+ reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
1130
+ blockedBy: "local-config",
1131
+ blockedByLabel: policyResult.blockedByLabel
1132
+ };
1133
+ }
1050
1134
  explainableLabel = policyResult.blockedByLabel || "Local Config";
1051
1135
  const persistent = getPersistentDecision(toolName);
1052
1136
  if (persistent === "allow") {
@@ -1115,6 +1199,25 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1115
1199
  const abortController = new AbortController();
1116
1200
  const { signal } = abortController;
1117
1201
  const racePromises = [];
1202
+ const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0;
1203
+ if (approvalTimeoutMs > 0) {
1204
+ racePromises.push(
1205
+ new Promise((resolve, reject) => {
1206
+ const timer = setTimeout(() => {
1207
+ resolve({
1208
+ approved: false,
1209
+ reason: `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy.`,
1210
+ blockedBy: "timeout",
1211
+ blockedByLabel: "Approval Timeout"
1212
+ });
1213
+ }, approvalTimeoutMs);
1214
+ signal.addEventListener("abort", () => {
1215
+ clearTimeout(timer);
1216
+ reject(new Error("Aborted"));
1217
+ });
1218
+ })
1219
+ );
1220
+ }
1118
1221
  let viewerId = null;
1119
1222
  const internalToken = getInternalToken();
1120
1223
  if (cloudEnforced && cloudRequestId) {
@@ -1315,7 +1418,8 @@ function getConfig() {
1315
1418
  dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
1316
1419
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
1317
1420
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
1318
- rules: [...DEFAULT_CONFIG.policy.rules]
1421
+ rules: [...DEFAULT_CONFIG.policy.rules],
1422
+ smartRules: [...DEFAULT_CONFIG.policy.smartRules]
1319
1423
  };
1320
1424
  const applyLayer = (source) => {
1321
1425
  if (!source) return;
@@ -1334,6 +1438,7 @@ function getConfig() {
1334
1438
  if (p.toolInspection)
1335
1439
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1336
1440
  if (p.rules) mergedPolicy.rules.push(...p.rules);
1441
+ if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1337
1442
  };
1338
1443
  applyLayer(globalConfig);
1339
1444
  applyLayer(projectConfig);
@@ -3838,6 +3943,74 @@ program.command("init").description("Create ~/.node9/config.json with default po
3838
3943
  chalk5.gray(` Undo Engine is ENABLED by default. Use 'node9 undo' to revert AI changes.`)
3839
3944
  );
3840
3945
  });
3946
+ function formatRelativeTime(timestamp) {
3947
+ const diff = Date.now() - new Date(timestamp).getTime();
3948
+ const sec = Math.floor(diff / 1e3);
3949
+ if (sec < 60) return `${sec}s ago`;
3950
+ const min = Math.floor(sec / 60);
3951
+ if (min < 60) return `${min}m ago`;
3952
+ const hrs = Math.floor(min / 60);
3953
+ if (hrs < 24) return `${hrs}h ago`;
3954
+ return new Date(timestamp).toLocaleDateString();
3955
+ }
3956
+ program.command("audit").description("View local execution audit log").option("--tail <n>", "Number of entries to show", "20").option("--tool <pattern>", "Filter by tool name (substring match)").option("--deny", "Show only denied actions").option("--json", "Output raw JSON").action((options) => {
3957
+ const logPath = path5.join(os5.homedir(), ".node9", "audit.log");
3958
+ if (!fs5.existsSync(logPath)) {
3959
+ console.log(
3960
+ chalk5.yellow("No audit logs found. Run node9 with an agent to generate entries.")
3961
+ );
3962
+ return;
3963
+ }
3964
+ const raw = fs5.readFileSync(logPath, "utf-8");
3965
+ const lines = raw.split("\n").filter((l) => l.trim() !== "");
3966
+ let entries = lines.flatMap((line) => {
3967
+ try {
3968
+ return [JSON.parse(line)];
3969
+ } catch {
3970
+ return [];
3971
+ }
3972
+ });
3973
+ entries = entries.map((e) => ({
3974
+ ...e,
3975
+ decision: String(e.decision).startsWith("allow") ? "allow" : "deny"
3976
+ }));
3977
+ if (options.tool) entries = entries.filter((e) => String(e.tool).includes(options.tool));
3978
+ if (options.deny) entries = entries.filter((e) => e.decision === "deny");
3979
+ const limit = Math.max(1, parseInt(options.tail, 10) || 20);
3980
+ entries = entries.slice(-limit);
3981
+ if (options.json) {
3982
+ console.log(JSON.stringify(entries, null, 2));
3983
+ return;
3984
+ }
3985
+ if (entries.length === 0) {
3986
+ console.log(chalk5.yellow("No matching audit entries."));
3987
+ return;
3988
+ }
3989
+ console.log(
3990
+ `
3991
+ ${chalk5.bold("Node9 Audit Log")} ${chalk5.dim(`(${entries.length} entries)`)}`
3992
+ );
3993
+ console.log(chalk5.dim(" " + "\u2500".repeat(65)));
3994
+ console.log(
3995
+ ` ${"Time".padEnd(12)} ${"Tool".padEnd(18)} ${"Result".padEnd(10)} ${"By".padEnd(15)} Agent`
3996
+ );
3997
+ console.log(chalk5.dim(" " + "\u2500".repeat(65)));
3998
+ for (const e of entries) {
3999
+ const time = formatRelativeTime(String(e.ts)).padEnd(12);
4000
+ const tool = String(e.tool).slice(0, 17).padEnd(18);
4001
+ const result = e.decision === "allow" ? chalk5.green("ALLOW".padEnd(10)) : chalk5.red("DENY".padEnd(10));
4002
+ const checker = String(e.checkedBy || "unknown").slice(0, 14).padEnd(15);
4003
+ const agent = String(e.agent || "unknown");
4004
+ console.log(` ${time} ${tool} ${result} ${checker} ${agent}`);
4005
+ }
4006
+ const allowed = entries.filter((e) => e.decision === "allow").length;
4007
+ const denied = entries.filter((e) => e.decision === "deny").length;
4008
+ console.log(chalk5.dim(" " + "\u2500".repeat(65)));
4009
+ console.log(
4010
+ ` ${entries.length} entries | ${chalk5.green(allowed + " allowed")} | ${chalk5.red(denied + " denied")}
4011
+ `
4012
+ );
4013
+ });
3841
4014
  program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
3842
4015
  const creds = getCredentials();
3843
4016
  const daemonRunning = isDaemonRunning();
package/dist/index.js CHANGED
@@ -342,6 +342,43 @@ function getNestedValue(obj, path2) {
342
342
  if (!obj || typeof obj !== "object") return null;
343
343
  return path2.split(".").reduce((prev, curr) => prev?.[curr], obj);
344
344
  }
345
+ function evaluateSmartConditions(args, rule) {
346
+ if (!rule.conditions || rule.conditions.length === 0) return true;
347
+ const mode = rule.conditionMode ?? "all";
348
+ const results = rule.conditions.map((cond) => {
349
+ const rawVal = getNestedValue(args, cond.field);
350
+ const val = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
351
+ switch (cond.op) {
352
+ case "exists":
353
+ return val !== null && val !== "";
354
+ case "notExists":
355
+ return val === null || val === "";
356
+ case "contains":
357
+ return val !== null && cond.value ? val.includes(cond.value) : false;
358
+ case "notContains":
359
+ return val !== null && cond.value ? !val.includes(cond.value) : true;
360
+ case "matches": {
361
+ if (val === null || !cond.value) return false;
362
+ try {
363
+ return new RegExp(cond.value, cond.flags ?? "").test(val);
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
368
+ case "notMatches": {
369
+ if (val === null || !cond.value) return true;
370
+ try {
371
+ return !new RegExp(cond.value, cond.flags ?? "").test(val);
372
+ } catch {
373
+ return true;
374
+ }
375
+ }
376
+ default:
377
+ return false;
378
+ }
379
+ });
380
+ return mode === "any" ? results.some((r) => r) : results.every((r) => r);
381
+ }
345
382
  function extractShellCommand(toolName, args, toolInspection) {
346
383
  const patterns = Object.keys(toolInspection);
347
384
  const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
@@ -358,15 +395,6 @@ function isSqlTool(toolName, toolInspection) {
358
395
  return fieldName === "sql" || fieldName === "query";
359
396
  }
360
397
  var SQL_DML_KEYWORDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "merge", "upsert"]);
361
- function checkDangerousSql(sql) {
362
- const norm = sql.replace(/\s+/g, " ").trim().toLowerCase();
363
- const hasWhere = /\bwhere\b/.test(norm);
364
- if (/^delete\s+from\s+\S+/.test(norm) && !hasWhere)
365
- return "DELETE without WHERE \u2014 full table wipe";
366
- if (/^update\s+\S+\s+set\s+/.test(norm) && !hasWhere)
367
- return "UPDATE without WHERE \u2014 updates every row";
368
- return null;
369
- }
370
398
  async function analyzeShellCommand(command) {
371
399
  const actions = [];
372
400
  const paths = [];
@@ -466,6 +494,8 @@ var DEFAULT_CONFIG = {
466
494
  enableUndo: true,
467
495
  // 🔥 ALWAYS TRUE BY DEFAULT for the safety net
468
496
  enableHookLogDebug: false,
497
+ approvalTimeoutMs: 0,
498
+ // 0 = disabled; set e.g. 30000 for 30-second auto-deny
469
499
  approvers: { native: true, browser: true, cloud: true, terminal: true }
470
500
  },
471
501
  policy: {
@@ -514,6 +544,19 @@ var DEFAULT_CONFIG = {
514
544
  ".DS_Store"
515
545
  ]
516
546
  }
547
+ ],
548
+ smartRules: [
549
+ {
550
+ name: "no-delete-without-where",
551
+ tool: "*",
552
+ conditions: [
553
+ { field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
554
+ { field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
555
+ ],
556
+ conditionMode: "all",
557
+ verdict: "review",
558
+ reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
559
+ }
517
560
  ]
518
561
  },
519
562
  environments: {}
@@ -533,6 +576,19 @@ function getInternalToken() {
533
576
  async function evaluatePolicy(toolName, args, agent) {
534
577
  const config = getConfig();
535
578
  if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
579
+ if (config.policy.smartRules.length > 0) {
580
+ const matchedRule = config.policy.smartRules.find(
581
+ (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
582
+ );
583
+ if (matchedRule) {
584
+ if (matchedRule.verdict === "allow") return { decision: "allow" };
585
+ return {
586
+ decision: matchedRule.verdict,
587
+ blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
588
+ reason: matchedRule.reason
589
+ };
590
+ }
591
+ }
536
592
  let allTokens = [];
537
593
  let actionTokens = [];
538
594
  let pathTokens = [];
@@ -547,8 +603,6 @@ async function evaluatePolicy(toolName, args, agent) {
547
603
  return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
548
604
  }
549
605
  if (isSqlTool(toolName, config.policy.toolInspection)) {
550
- const sqlDanger = checkDangerousSql(shellCommand);
551
- if (sqlDanger) return { decision: "review", blockedByLabel: `SQL Safety: ${sqlDanger}` };
552
606
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
553
607
  actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
554
608
  }
@@ -762,6 +816,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
762
816
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
763
817
  return { approved: true, checkedBy: "local-policy" };
764
818
  }
819
+ if (policyResult.decision === "block") {
820
+ if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
821
+ return {
822
+ approved: false,
823
+ reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
824
+ blockedBy: "local-config",
825
+ blockedByLabel: policyResult.blockedByLabel
826
+ };
827
+ }
765
828
  explainableLabel = policyResult.blockedByLabel || "Local Config";
766
829
  const persistent = getPersistentDecision(toolName);
767
830
  if (persistent === "allow") {
@@ -830,6 +893,25 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
830
893
  const abortController = new AbortController();
831
894
  const { signal } = abortController;
832
895
  const racePromises = [];
896
+ const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0;
897
+ if (approvalTimeoutMs > 0) {
898
+ racePromises.push(
899
+ new Promise((resolve, reject) => {
900
+ const timer = setTimeout(() => {
901
+ resolve({
902
+ approved: false,
903
+ reason: `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy.`,
904
+ blockedBy: "timeout",
905
+ blockedByLabel: "Approval Timeout"
906
+ });
907
+ }, approvalTimeoutMs);
908
+ signal.addEventListener("abort", () => {
909
+ clearTimeout(timer);
910
+ reject(new Error("Aborted"));
911
+ });
912
+ })
913
+ );
914
+ }
833
915
  let viewerId = null;
834
916
  const internalToken = getInternalToken();
835
917
  if (cloudEnforced && cloudRequestId) {
@@ -1030,7 +1112,8 @@ function getConfig() {
1030
1112
  dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
1031
1113
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
1032
1114
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
1033
- rules: [...DEFAULT_CONFIG.policy.rules]
1115
+ rules: [...DEFAULT_CONFIG.policy.rules],
1116
+ smartRules: [...DEFAULT_CONFIG.policy.smartRules]
1034
1117
  };
1035
1118
  const applyLayer = (source) => {
1036
1119
  if (!source) return;
@@ -1049,6 +1132,7 @@ function getConfig() {
1049
1132
  if (p.toolInspection)
1050
1133
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1051
1134
  if (p.rules) mergedPolicy.rules.push(...p.rules);
1135
+ if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1052
1136
  };
1053
1137
  applyLayer(globalConfig);
1054
1138
  applyLayer(projectConfig);
package/dist/index.mjs CHANGED
@@ -306,6 +306,43 @@ function getNestedValue(obj, path2) {
306
306
  if (!obj || typeof obj !== "object") return null;
307
307
  return path2.split(".").reduce((prev, curr) => prev?.[curr], obj);
308
308
  }
309
+ function evaluateSmartConditions(args, rule) {
310
+ if (!rule.conditions || rule.conditions.length === 0) return true;
311
+ const mode = rule.conditionMode ?? "all";
312
+ const results = rule.conditions.map((cond) => {
313
+ const rawVal = getNestedValue(args, cond.field);
314
+ const val = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
315
+ switch (cond.op) {
316
+ case "exists":
317
+ return val !== null && val !== "";
318
+ case "notExists":
319
+ return val === null || val === "";
320
+ case "contains":
321
+ return val !== null && cond.value ? val.includes(cond.value) : false;
322
+ case "notContains":
323
+ return val !== null && cond.value ? !val.includes(cond.value) : true;
324
+ case "matches": {
325
+ if (val === null || !cond.value) return false;
326
+ try {
327
+ return new RegExp(cond.value, cond.flags ?? "").test(val);
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+ case "notMatches": {
333
+ if (val === null || !cond.value) return true;
334
+ try {
335
+ return !new RegExp(cond.value, cond.flags ?? "").test(val);
336
+ } catch {
337
+ return true;
338
+ }
339
+ }
340
+ default:
341
+ return false;
342
+ }
343
+ });
344
+ return mode === "any" ? results.some((r) => r) : results.every((r) => r);
345
+ }
309
346
  function extractShellCommand(toolName, args, toolInspection) {
310
347
  const patterns = Object.keys(toolInspection);
311
348
  const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
@@ -322,15 +359,6 @@ function isSqlTool(toolName, toolInspection) {
322
359
  return fieldName === "sql" || fieldName === "query";
323
360
  }
324
361
  var SQL_DML_KEYWORDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "merge", "upsert"]);
325
- function checkDangerousSql(sql) {
326
- const norm = sql.replace(/\s+/g, " ").trim().toLowerCase();
327
- const hasWhere = /\bwhere\b/.test(norm);
328
- if (/^delete\s+from\s+\S+/.test(norm) && !hasWhere)
329
- return "DELETE without WHERE \u2014 full table wipe";
330
- if (/^update\s+\S+\s+set\s+/.test(norm) && !hasWhere)
331
- return "UPDATE without WHERE \u2014 updates every row";
332
- return null;
333
- }
334
362
  async function analyzeShellCommand(command) {
335
363
  const actions = [];
336
364
  const paths = [];
@@ -430,6 +458,8 @@ var DEFAULT_CONFIG = {
430
458
  enableUndo: true,
431
459
  // 🔥 ALWAYS TRUE BY DEFAULT for the safety net
432
460
  enableHookLogDebug: false,
461
+ approvalTimeoutMs: 0,
462
+ // 0 = disabled; set e.g. 30000 for 30-second auto-deny
433
463
  approvers: { native: true, browser: true, cloud: true, terminal: true }
434
464
  },
435
465
  policy: {
@@ -478,6 +508,19 @@ var DEFAULT_CONFIG = {
478
508
  ".DS_Store"
479
509
  ]
480
510
  }
511
+ ],
512
+ smartRules: [
513
+ {
514
+ name: "no-delete-without-where",
515
+ tool: "*",
516
+ conditions: [
517
+ { field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
518
+ { field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
519
+ ],
520
+ conditionMode: "all",
521
+ verdict: "review",
522
+ reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
523
+ }
481
524
  ]
482
525
  },
483
526
  environments: {}
@@ -497,6 +540,19 @@ function getInternalToken() {
497
540
  async function evaluatePolicy(toolName, args, agent) {
498
541
  const config = getConfig();
499
542
  if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
543
+ if (config.policy.smartRules.length > 0) {
544
+ const matchedRule = config.policy.smartRules.find(
545
+ (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
546
+ );
547
+ if (matchedRule) {
548
+ if (matchedRule.verdict === "allow") return { decision: "allow" };
549
+ return {
550
+ decision: matchedRule.verdict,
551
+ blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
552
+ reason: matchedRule.reason
553
+ };
554
+ }
555
+ }
500
556
  let allTokens = [];
501
557
  let actionTokens = [];
502
558
  let pathTokens = [];
@@ -511,8 +567,6 @@ async function evaluatePolicy(toolName, args, agent) {
511
567
  return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
512
568
  }
513
569
  if (isSqlTool(toolName, config.policy.toolInspection)) {
514
- const sqlDanger = checkDangerousSql(shellCommand);
515
- if (sqlDanger) return { decision: "review", blockedByLabel: `SQL Safety: ${sqlDanger}` };
516
570
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
517
571
  actionTokens = actionTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
518
572
  }
@@ -726,6 +780,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
726
780
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
727
781
  return { approved: true, checkedBy: "local-policy" };
728
782
  }
783
+ if (policyResult.decision === "block") {
784
+ if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
785
+ return {
786
+ approved: false,
787
+ reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
788
+ blockedBy: "local-config",
789
+ blockedByLabel: policyResult.blockedByLabel
790
+ };
791
+ }
729
792
  explainableLabel = policyResult.blockedByLabel || "Local Config";
730
793
  const persistent = getPersistentDecision(toolName);
731
794
  if (persistent === "allow") {
@@ -794,6 +857,25 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
794
857
  const abortController = new AbortController();
795
858
  const { signal } = abortController;
796
859
  const racePromises = [];
860
+ const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0;
861
+ if (approvalTimeoutMs > 0) {
862
+ racePromises.push(
863
+ new Promise((resolve, reject) => {
864
+ const timer = setTimeout(() => {
865
+ resolve({
866
+ approved: false,
867
+ reason: `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy.`,
868
+ blockedBy: "timeout",
869
+ blockedByLabel: "Approval Timeout"
870
+ });
871
+ }, approvalTimeoutMs);
872
+ signal.addEventListener("abort", () => {
873
+ clearTimeout(timer);
874
+ reject(new Error("Aborted"));
875
+ });
876
+ })
877
+ );
878
+ }
797
879
  let viewerId = null;
798
880
  const internalToken = getInternalToken();
799
881
  if (cloudEnforced && cloudRequestId) {
@@ -994,7 +1076,8 @@ function getConfig() {
994
1076
  dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
995
1077
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
996
1078
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
997
- rules: [...DEFAULT_CONFIG.policy.rules]
1079
+ rules: [...DEFAULT_CONFIG.policy.rules],
1080
+ smartRules: [...DEFAULT_CONFIG.policy.smartRules]
998
1081
  };
999
1082
  const applyLayer = (source) => {
1000
1083
  if (!source) return;
@@ -1013,6 +1096,7 @@ function getConfig() {
1013
1096
  if (p.toolInspection)
1014
1097
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1015
1098
  if (p.rules) mergedPolicy.rules.push(...p.rules);
1099
+ if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1016
1100
  };
1017
1101
  applyLayer(globalConfig);
1018
1102
  applyLayer(projectConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code & MCP.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",