@node9/proxy 1.0.4 → 1.0.6
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 +209 -8
- package/dist/cli.js +204 -31
- package/dist/cli.mjs +204 -31
- package/dist/index.js +96 -12
- package/dist/index.mjs +96 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,11 +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
|
-
<<<<<<< dev
|
|
20
|
-
<img src="https://github.com/user-attachments/assets/afae9caa-0605-4cac-929a-c14198383169" width="100%">
|
|
21
|
-
=======
|
|
22
19
|
<img src="https://github.com/user-attachments/assets/0e45e843-4cf7-408e-95ce-23fb09525ee4" width="100%">
|
|
23
|
-
>>>>>>> main
|
|
24
20
|
</p>
|
|
25
21
|
|
|
26
22
|
**With Node9, the interaction looks like this:**
|
|
@@ -79,6 +75,8 @@ Revert to this snapshot? [y/N]
|
|
|
79
75
|
|
|
80
76
|
Node9 keeps the last 10 snapshots. Snapshots are only taken for file-writing tools (`write_file`, `edit_file`, `str_replace_based_edit_tool`, `create_file`) — not for read-only or shell commands.
|
|
81
77
|
|
|
78
|
+
Node9 keeps the last 10 snapshots. Snapshots are only taken for file-writing tools (`write_file`, `edit_file`, `str_replace_based_edit_tool`, `create_file`) — not for read-only or shell commands.
|
|
79
|
+
|
|
82
80
|
### 🌊 The Resolution Waterfall
|
|
83
81
|
|
|
84
82
|
Security posture is resolved using a strict 5-tier waterfall:
|
|
@@ -97,13 +95,17 @@ Security posture is resolved using a strict 5-tier waterfall:
|
|
|
97
95
|
npm install -g @node9/proxy
|
|
98
96
|
|
|
99
97
|
# 1. Setup protection for your favorite agent
|
|
100
|
-
node9
|
|
98
|
+
node9 setup # interactive menu — picks the right agent for you
|
|
99
|
+
node9 addto claude # or wire directly
|
|
101
100
|
node9 addto gemini
|
|
102
101
|
|
|
103
102
|
# 2. Initialize your local safety net
|
|
104
103
|
node9 init
|
|
105
104
|
|
|
106
|
-
# 3.
|
|
105
|
+
# 3. Verify everything is wired correctly
|
|
106
|
+
node9 doctor
|
|
107
|
+
|
|
108
|
+
# 4. Check your status
|
|
107
109
|
node9 status
|
|
108
110
|
```
|
|
109
111
|
|
|
@@ -128,6 +130,7 @@ Rules are **merged additive**—you cannot "un-danger" a word locally if it was
|
|
|
128
130
|
"settings": {
|
|
129
131
|
"mode": "standard",
|
|
130
132
|
"enableUndo": true,
|
|
133
|
+
"approvalTimeoutMs": 30000,
|
|
131
134
|
"approvers": {
|
|
132
135
|
"native": true,
|
|
133
136
|
"browser": true,
|
|
@@ -142,13 +145,211 @@ Rules are **merged additive**—you cannot "un-danger" a word locally if it was
|
|
|
142
145
|
"toolInspection": {
|
|
143
146
|
"bash": "command",
|
|
144
147
|
"postgres:query": "sql"
|
|
145
|
-
}
|
|
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
|
+
]
|
|
146
165
|
}
|
|
147
166
|
}
|
|
148
167
|
```
|
|
149
168
|
|
|
169
|
+
### ⚙️ `settings` options
|
|
170
|
+
|
|
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
|
+
|
|
150
235
|
---
|
|
151
236
|
|
|
237
|
+
## 🖥️ CLI Reference
|
|
238
|
+
|
|
239
|
+
| Command | Description |
|
|
240
|
+
| :---------------------------- | :------------------------------------------------------------------------------------ |
|
|
241
|
+
| `node9 setup` | Interactive menu — detects installed agents and wires hooks for you |
|
|
242
|
+
| `node9 addto <agent>` | Wire hooks for a specific agent (`claude`, `gemini`, `cursor`) |
|
|
243
|
+
| `node9 init` | Create default `~/.node9/config.json` |
|
|
244
|
+
| `node9 status` | Show current protection status and active rules |
|
|
245
|
+
| `node9 doctor` | Health check — verifies binaries, config, credentials, and all agent hooks |
|
|
246
|
+
| `node9 explain <tool> [args]` | Trace the policy waterfall for a given tool call (dry-run, no approval prompt) |
|
|
247
|
+
| `node9 undo [--steps N]` | Revert the last N AI file edits using shadow Git snapshots |
|
|
248
|
+
| `node9 check` | Called by agent hooks; evaluates a pending tool call and exits 0 (allow) or 1 (block) |
|
|
249
|
+
|
|
250
|
+
### `node9 doctor`
|
|
251
|
+
|
|
252
|
+
Runs a full self-test and exits 1 if any required check fails:
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
Node9 Doctor v1.2.0
|
|
256
|
+
────────────────────────────────────────
|
|
257
|
+
Binaries
|
|
258
|
+
✅ Node.js v20.11.0
|
|
259
|
+
✅ git version 2.43.0
|
|
260
|
+
|
|
261
|
+
Configuration
|
|
262
|
+
✅ ~/.node9/config.json found and valid
|
|
263
|
+
✅ ~/.node9/credentials.json — cloud credentials found
|
|
264
|
+
|
|
265
|
+
Agent Hooks
|
|
266
|
+
✅ Claude Code — PreToolUse hook active
|
|
267
|
+
⚠️ Gemini CLI — not configured (optional)
|
|
268
|
+
⚠️ Cursor — not configured (optional)
|
|
269
|
+
|
|
270
|
+
────────────────────────────────────────
|
|
271
|
+
All checks passed ✅
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### `node9 explain`
|
|
275
|
+
|
|
276
|
+
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:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
node9 explain bash '{"command":"rm -rf /tmp/build"}'
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
Policy Waterfall for: bash
|
|
284
|
+
──────────────────────────────────────────────
|
|
285
|
+
Tier 1 · Cloud Org Policy SKIP (no org policy loaded)
|
|
286
|
+
Tier 2 · Dangerous Words BLOCK ← matched "rm -rf"
|
|
287
|
+
Tier 3 · Path Block –
|
|
288
|
+
Tier 4 · Inline Exec –
|
|
289
|
+
Tier 5 · Rule Match –
|
|
290
|
+
──────────────────────────────────────────────
|
|
291
|
+
Verdict: BLOCK (dangerous word: rm -rf)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
---
|
|
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
|
+
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:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
node9 explain bash '{"command":"rm -rf /tmp/build"}'
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
Policy Waterfall for: bash
|
|
343
|
+
──────────────────────────────────────────────
|
|
344
|
+
Tier 1 · Cloud Org Policy SKIP (no org policy loaded)
|
|
345
|
+
Tier 2 · Dangerous Words BLOCK ← matched "rm -rf"
|
|
346
|
+
Tier 3 · Path Block –
|
|
347
|
+
Tier 4 · Inline Exec –
|
|
348
|
+
Tier 5 · Rule Match –
|
|
349
|
+
──────────────────────────────────────────────
|
|
350
|
+
Verdict: BLOCK (dangerous word: rm -rf)
|
|
351
|
+
```
|
|
352
|
+
|
|
152
353
|
---
|
|
153
354
|
|
|
154
355
|
## 🔧 Troubleshooting
|
|
@@ -181,4 +382,4 @@ A corporate policy has locked this action. You must click the "Approve" button i
|
|
|
181
382
|
## 🏢 Enterprise & Compliance
|
|
182
383
|
|
|
183
384
|
Node9 Pro provides **Governance Locking**, **SAML/SSO**, and **VPC Deployment**.
|
|
184
|
-
Visit [node9.ai](https://node9.ai
|
|
385
|
+
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
|
|
845
|
+
name: "SQL token stripping",
|
|
771
846
|
outcome: "checked",
|
|
772
|
-
detail: "
|
|
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
|
|
822
|
+
name: "SQL token stripping",
|
|
748
823
|
outcome: "checked",
|
|
749
|
-
detail: "
|
|
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);
|