@node9/proxy 1.14.0 → 1.15.0
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 +17 -0
- package/dist/cli.js +3136 -2165
- package/dist/cli.mjs +3117 -2147
- package/dist/index.js +2511 -1709
- package/dist/index.mjs +2511 -1709
- package/package.json +5 -2
- package/dist/shields/builtin/aws.json +0 -59
- package/dist/shields/builtin/bash-safe.json +0 -92
- package/dist/shields/builtin/docker.json +0 -120
- package/dist/shields/builtin/filesystem.json +0 -30
- package/dist/shields/builtin/github.json +0 -26
- package/dist/shields/builtin/k8s.json +0 -92
- package/dist/shields/builtin/mcp-tool-gating.json +0 -7
- package/dist/shields/builtin/mongodb.json +0 -78
- package/dist/shields/builtin/postgres.json +0 -42
- package/dist/shields/builtin/project-jail.json +0 -64
- package/dist/shields/builtin/redis.json +0 -78
package/dist/index.mjs
CHANGED
|
@@ -258,8 +258,8 @@ function sanitizeConfig(raw) {
|
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
const lines = result.error.issues.map((issue) => {
|
|
261
|
-
const
|
|
262
|
-
return ` \u2022 ${
|
|
261
|
+
const path13 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
262
|
+
return ` \u2022 ${path13}: ${issue.message}`;
|
|
263
263
|
});
|
|
264
264
|
return {
|
|
265
265
|
sanitized,
|
|
@@ -272,783 +272,101 @@ ${lines.join("\n")}`
|
|
|
272
272
|
import fs2 from "fs";
|
|
273
273
|
import path2 from "path";
|
|
274
274
|
import os2 from "os";
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
`);
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
if (typeof r.description !== "string") {
|
|
290
|
-
process.stderr.write(`[node9] Shield file missing 'description': ${filePath}
|
|
291
|
-
`);
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
if (!Array.isArray(r.aliases)) {
|
|
295
|
-
process.stderr.write(`[node9] Shield file missing 'aliases' array: ${filePath}
|
|
296
|
-
`);
|
|
297
|
-
return null;
|
|
298
|
-
}
|
|
299
|
-
if (!Array.isArray(r.smartRules)) {
|
|
300
|
-
process.stderr.write(`[node9] Shield file missing 'smartRules' array: ${filePath}
|
|
301
|
-
`);
|
|
302
|
-
return null;
|
|
303
|
-
}
|
|
304
|
-
if (!Array.isArray(r.dangerousWords)) {
|
|
305
|
-
process.stderr.write(`[node9] Shield file missing 'dangerousWords' array: ${filePath}
|
|
306
|
-
`);
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
return r;
|
|
310
|
-
}
|
|
311
|
-
function loadShieldsFromDir(dir, label) {
|
|
312
|
-
const result = {};
|
|
313
|
-
let entries;
|
|
314
|
-
try {
|
|
315
|
-
entries = fs2.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
316
|
-
} catch (err) {
|
|
317
|
-
if (err.code !== "ENOENT") {
|
|
318
|
-
process.stderr.write(`[node9] Could not read ${label} shields dir ${dir}: ${String(err)}
|
|
319
|
-
`);
|
|
320
|
-
}
|
|
321
|
-
return result;
|
|
322
|
-
}
|
|
323
|
-
for (const file of entries) {
|
|
324
|
-
const filePath = path2.join(dir, file);
|
|
325
|
-
try {
|
|
326
|
-
const raw = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
|
|
327
|
-
const shield = validateShieldDefinition(raw, filePath);
|
|
328
|
-
if (shield) result[shield.name] = shield;
|
|
329
|
-
} catch (err) {
|
|
330
|
-
process.stderr.write(`[node9] Failed to load ${label} shield ${file}: ${String(err)}
|
|
331
|
-
`);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
return result;
|
|
335
|
-
}
|
|
336
|
-
function buildSHIELDS() {
|
|
337
|
-
const builtins = loadShieldsFromDir(BUILTIN_DIR, "builtin");
|
|
338
|
-
const userShields = loadShieldsFromDir(USER_SHIELDS_DIR, "user");
|
|
339
|
-
return { ...builtins, ...userShields };
|
|
340
|
-
}
|
|
341
|
-
var SHIELDS = buildSHIELDS();
|
|
342
|
-
function resolveShieldName(input) {
|
|
343
|
-
const lower = input.toLowerCase();
|
|
344
|
-
if (SHIELDS[lower]) return lower;
|
|
345
|
-
for (const [name, def] of Object.entries(SHIELDS)) {
|
|
346
|
-
if (def.aliases.includes(lower)) return name;
|
|
347
|
-
}
|
|
348
|
-
return null;
|
|
349
|
-
}
|
|
350
|
-
function getShield(name) {
|
|
351
|
-
const resolved = resolveShieldName(name);
|
|
352
|
-
return resolved ? SHIELDS[resolved] : null;
|
|
353
|
-
}
|
|
354
|
-
var SHIELDS_STATE_FILE = path2.join(os2.homedir(), ".node9", "shields.json");
|
|
355
|
-
function isShieldVerdict(v) {
|
|
356
|
-
return v === "allow" || v === "review" || v === "block";
|
|
357
|
-
}
|
|
358
|
-
function validateOverrides(raw) {
|
|
359
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
360
|
-
const result = {};
|
|
361
|
-
for (const [shieldName, rules] of Object.entries(raw)) {
|
|
362
|
-
if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
|
|
363
|
-
const validRules = {};
|
|
364
|
-
for (const [ruleName, verdict] of Object.entries(rules)) {
|
|
365
|
-
if (isShieldVerdict(verdict)) {
|
|
366
|
-
validRules[ruleName] = verdict;
|
|
367
|
-
} else {
|
|
368
|
-
process.stderr.write(
|
|
369
|
-
`[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
|
|
370
|
-
`
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
|
|
375
|
-
}
|
|
376
|
-
return result;
|
|
275
|
+
|
|
276
|
+
// packages/policy-engine/dist/index.mjs
|
|
277
|
+
import safeRegex from "safe-regex2";
|
|
278
|
+
import mvdanSh from "mvdan-sh";
|
|
279
|
+
import pm from "picomatch";
|
|
280
|
+
import safeRegex2 from "safe-regex2";
|
|
281
|
+
import safeRegex3 from "safe-regex2";
|
|
282
|
+
import crypto from "crypto";
|
|
283
|
+
var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
|
|
284
|
+
function isAssignmentContext(text) {
|
|
285
|
+
return ASSIGNMENT_CONTEXT_RE.test(text);
|
|
377
286
|
}
|
|
378
|
-
function
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return { active, overrides: validateOverrides(parsed.overrides) };
|
|
387
|
-
} catch (err) {
|
|
388
|
-
if (err.code !== "ENOENT") {
|
|
389
|
-
process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
|
|
390
|
-
`);
|
|
391
|
-
}
|
|
392
|
-
return { active: [] };
|
|
287
|
+
function shannonEntropy(s) {
|
|
288
|
+
if (s.length === 0) return 0;
|
|
289
|
+
const freq = /* @__PURE__ */ new Map();
|
|
290
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
291
|
+
let h = 0;
|
|
292
|
+
for (const count of freq.values()) {
|
|
293
|
+
const p = count / s.length;
|
|
294
|
+
h -= p * Math.log2(p);
|
|
393
295
|
}
|
|
296
|
+
return h;
|
|
394
297
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
"
|
|
405
|
-
|
|
406
|
-
"
|
|
407
|
-
|
|
298
|
+
var DLP_STOPWORDS = [
|
|
299
|
+
"example",
|
|
300
|
+
"placeholder",
|
|
301
|
+
"changeme",
|
|
302
|
+
"your_key",
|
|
303
|
+
"your_token",
|
|
304
|
+
"your_secret",
|
|
305
|
+
"replace_me",
|
|
306
|
+
"insert_key",
|
|
307
|
+
"put_your",
|
|
308
|
+
"fake",
|
|
309
|
+
"dummy",
|
|
310
|
+
"sample",
|
|
311
|
+
"xxxxxxxx",
|
|
312
|
+
"aaaaaa",
|
|
313
|
+
"bbbbbb",
|
|
314
|
+
"00000000",
|
|
315
|
+
"${",
|
|
316
|
+
"{{",
|
|
317
|
+
"%{",
|
|
318
|
+
"<your",
|
|
319
|
+
"test_key",
|
|
320
|
+
"test_token",
|
|
321
|
+
"your",
|
|
322
|
+
"here"
|
|
408
323
|
];
|
|
409
|
-
var
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
enableHookLogDebug: true,
|
|
417
|
-
approvalTimeoutMs: 12e4,
|
|
418
|
-
// 120-second auto-deny timeout
|
|
419
|
-
flightRecorder: true,
|
|
420
|
-
auditHashArgs: true,
|
|
421
|
-
approvers: { native: true, browser: false, cloud: false, terminal: true },
|
|
422
|
-
cloudSyncIntervalHours: 5
|
|
324
|
+
var DLP_PATTERNS = [
|
|
325
|
+
// ── AWS ───────────────────────────────────────────────────────────────────
|
|
326
|
+
{
|
|
327
|
+
name: "AWS Access Key ID",
|
|
328
|
+
regex: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/,
|
|
329
|
+
severity: "block",
|
|
330
|
+
keywords: ["akia", "asia", "abia", "acca", "a3t"]
|
|
423
331
|
},
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
"describe_*",
|
|
432
|
-
"read",
|
|
433
|
-
"glob",
|
|
434
|
-
"grep",
|
|
435
|
-
"ls",
|
|
436
|
-
"notebookread",
|
|
437
|
-
"notebookedit",
|
|
438
|
-
"webfetch",
|
|
439
|
-
"websearch",
|
|
440
|
-
"exitplanmode",
|
|
441
|
-
"askuserquestion",
|
|
442
|
-
"agent",
|
|
443
|
-
"task*",
|
|
444
|
-
"toolsearch",
|
|
445
|
-
"mcp__ide__*",
|
|
446
|
-
"getDiagnostics"
|
|
447
|
-
],
|
|
448
|
-
toolInspection: {
|
|
449
|
-
bash: "command",
|
|
450
|
-
shell: "command",
|
|
451
|
-
run_shell_command: "command",
|
|
452
|
-
"terminal.execute": "command",
|
|
453
|
-
"postgres:query": "sql"
|
|
454
|
-
},
|
|
455
|
-
snapshot: {
|
|
456
|
-
tools: [
|
|
457
|
-
"str_replace_based_edit_tool",
|
|
458
|
-
"write_file",
|
|
459
|
-
"edit_file",
|
|
460
|
-
"create_file",
|
|
461
|
-
"edit",
|
|
462
|
-
"replace"
|
|
463
|
-
],
|
|
464
|
-
onlyPaths: [],
|
|
465
|
-
ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
|
|
466
|
-
},
|
|
467
|
-
smartRules: [
|
|
468
|
-
// ── rm safety (critical — always evaluated first) ──────────────────────
|
|
469
|
-
{
|
|
470
|
-
name: "block-rm-rf-home",
|
|
471
|
-
tool: "bash",
|
|
472
|
-
conditionMode: "all",
|
|
473
|
-
conditions: [
|
|
474
|
-
{
|
|
475
|
-
field: "command",
|
|
476
|
-
op: "matches",
|
|
477
|
-
// Anchor rm as a shell command (not inside a string arg like a git commit message).
|
|
478
|
-
value: "(^|&&|\\|\\||;)\\s*rm\\b[^;&|]*\\s(-[rRfF]*[rR][rRfF]*|--recursive)(\\s|$)"
|
|
479
|
-
},
|
|
480
|
-
{
|
|
481
|
-
field: "command",
|
|
482
|
-
op: "matches",
|
|
483
|
-
value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
|
|
484
|
-
}
|
|
485
|
-
],
|
|
486
|
-
verdict: "block",
|
|
487
|
-
reason: "Recursive delete of home directory is irreversible",
|
|
488
|
-
description: "The AI wants to recursively delete your home directory. This will permanently destroy all your personal files and cannot be undone."
|
|
489
|
-
},
|
|
490
|
-
// ── SQL safety ────────────────────────────────────────────────────────
|
|
491
|
-
{
|
|
492
|
-
name: "no-delete-without-where",
|
|
493
|
-
tool: "*",
|
|
494
|
-
conditions: [
|
|
495
|
-
{ field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
|
|
496
|
-
{ field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
|
|
497
|
-
],
|
|
498
|
-
conditionMode: "all",
|
|
499
|
-
verdict: "review",
|
|
500
|
-
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table",
|
|
501
|
-
description: "The AI is running a SQL statement that will modify every row in the table \u2014 no WHERE filter was found. This could wipe or corrupt all your data."
|
|
502
|
-
},
|
|
503
|
-
{
|
|
504
|
-
name: "review-drop-truncate-shell",
|
|
505
|
-
tool: "bash",
|
|
506
|
-
conditions: [
|
|
507
|
-
{
|
|
508
|
-
field: "command",
|
|
509
|
-
op: "matches",
|
|
510
|
-
// Require a DB CLI in the command so grep/cat/echo of SQL strings don't trigger.
|
|
511
|
-
value: "(^|&&|\\|\\||;|\\|)\\s*(psql|mysql|sqlite3|sqlplus|cockroach|clickhouse-client|mongo)\\b",
|
|
512
|
-
flags: "i"
|
|
513
|
-
},
|
|
514
|
-
{
|
|
515
|
-
field: "command",
|
|
516
|
-
op: "matches",
|
|
517
|
-
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
518
|
-
flags: "i"
|
|
519
|
-
}
|
|
520
|
-
],
|
|
521
|
-
conditionMode: "all",
|
|
522
|
-
verdict: "review",
|
|
523
|
-
reason: "SQL DDL destructive statement inside a shell command",
|
|
524
|
-
description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
|
|
525
|
-
},
|
|
526
|
-
// ── Git safety ────────────────────────────────────────────────────────
|
|
527
|
-
{
|
|
528
|
-
name: "review-force-push",
|
|
529
|
-
tool: "bash",
|
|
530
|
-
conditions: [
|
|
531
|
-
{
|
|
532
|
-
field: "command",
|
|
533
|
-
op: "matches",
|
|
534
|
-
// Anchor git as a shell command so node -e / python -c scripts containing
|
|
535
|
-
// "git push --force" as a string don't false-positive.
|
|
536
|
-
value: "(^|&&|\\|\\||;)\\s*git\\s+push[^;&|]*(--force|--force-with-lease|-f\\b)",
|
|
537
|
-
flags: "i"
|
|
538
|
-
}
|
|
539
|
-
],
|
|
540
|
-
conditionMode: "all",
|
|
541
|
-
verdict: "review",
|
|
542
|
-
reason: "Force push rewrites remote history \u2014 confirm this is intentional",
|
|
543
|
-
description: "The AI wants to force push to a remote git branch. This rewrites shared history and can permanently destroy commits that teammates have already pulled."
|
|
544
|
-
},
|
|
545
|
-
{
|
|
546
|
-
name: "review-git-destructive",
|
|
547
|
-
tool: "bash",
|
|
548
|
-
conditions: [
|
|
549
|
-
{
|
|
550
|
-
field: "command",
|
|
551
|
-
op: "matches",
|
|
552
|
-
// Anchor git as a shell command so node -e / python -c scripts containing
|
|
553
|
-
// "git reset --hard" as a string don't false-positive.
|
|
554
|
-
value: "(^|&&|\\|\\||;)\\s*git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
555
|
-
flags: "i"
|
|
556
|
-
},
|
|
557
|
-
{
|
|
558
|
-
field: "command",
|
|
559
|
-
op: "notMatches",
|
|
560
|
-
// Exclude recovery ops and routine branch-surgery (--onto) — these are not destructive.
|
|
561
|
-
value: "\\bgit\\s+rebase\\s+--(abort|continue|skip|onto)\\b",
|
|
562
|
-
flags: "i"
|
|
563
|
-
}
|
|
564
|
-
],
|
|
565
|
-
conditionMode: "all",
|
|
566
|
-
verdict: "review",
|
|
567
|
-
reason: "Destructive git operation \u2014 discards history or working-tree changes",
|
|
568
|
-
description: "The AI wants to run a destructive git operation (reset, rebase, clean, or branch delete) that can permanently discard commits or uncommitted work."
|
|
569
|
-
},
|
|
570
|
-
// ── Shell safety ──────────────────────────────────────────────────────
|
|
571
|
-
{
|
|
572
|
-
name: "review-sudo",
|
|
573
|
-
tool: "bash",
|
|
574
|
-
conditions: [{ field: "command", op: "matches", value: "\\bsudo\\s", flags: "i" }],
|
|
575
|
-
conditionMode: "all",
|
|
576
|
-
verdict: "review",
|
|
577
|
-
reason: "Command requires elevated privileges",
|
|
578
|
-
description: "The AI wants to run a command as root (sudo). Commands with root access can modify system files, install software, or change security settings."
|
|
579
|
-
},
|
|
580
|
-
{
|
|
581
|
-
name: "review-curl-pipe-shell",
|
|
582
|
-
tool: "bash",
|
|
583
|
-
conditions: [
|
|
584
|
-
{
|
|
585
|
-
field: "command",
|
|
586
|
-
op: "matches",
|
|
587
|
-
// Anchor curl/wget as a shell command so node -e scripts testing this
|
|
588
|
-
// regex pattern don't self-match as a false positive.
|
|
589
|
-
value: "(^|&&|\\|\\||;)\\s*(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
590
|
-
flags: "i"
|
|
591
|
-
}
|
|
592
|
-
],
|
|
593
|
-
conditionMode: "all",
|
|
594
|
-
verdict: "block",
|
|
595
|
-
reason: "Piping remote script into a shell is a supply-chain attack vector",
|
|
596
|
-
description: "The AI wants to download a script from the internet and run it immediately, without you seeing what it contains. This is one of the most common ways malware gets installed."
|
|
597
|
-
}
|
|
598
|
-
],
|
|
599
|
-
dlp: { enabled: true, scanIgnoredTools: true },
|
|
600
|
-
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
|
|
601
|
-
skillPinning: { enabled: false, mode: "warn", roots: [] }
|
|
332
|
+
// ── GitHub ────────────────────────────────────────────────────────────────
|
|
333
|
+
{
|
|
334
|
+
name: "GitHub Token",
|
|
335
|
+
regex: /\bgh[pous]_[A-Za-z0-9]{36}\b/,
|
|
336
|
+
severity: "block",
|
|
337
|
+
keywords: ["ghp_", "gho_", "ghu_", "ghs_"],
|
|
338
|
+
minEntropy: 3
|
|
602
339
|
},
|
|
603
|
-
environments: {}
|
|
604
|
-
};
|
|
605
|
-
var ADVISORY_SMART_RULES = [
|
|
606
|
-
// ── rm safety ─────────────────────────────────────────────────────────────
|
|
607
|
-
// tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
|
|
608
|
-
// Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
|
|
609
|
-
// chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
|
|
610
340
|
{
|
|
611
|
-
name: "
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" },
|
|
616
|
-
{
|
|
617
|
-
field: "command",
|
|
618
|
-
op: "matches",
|
|
619
|
-
// Matches known-safe build artifact paths in the command.
|
|
620
|
-
value: "(node_modules|\\bdist\\b|\\.next|\\bcoverage\\b|\\.cache|\\btmp\\b|\\btemp\\b|\\.DS_Store)(\\/|\\s|$)"
|
|
621
|
-
}
|
|
622
|
-
],
|
|
623
|
-
verdict: "allow",
|
|
624
|
-
reason: "Deleting a known-safe build artifact path"
|
|
341
|
+
name: "GitHub Fine-Grained PAT",
|
|
342
|
+
regex: /\bgithub_pat_\w{82}\b/,
|
|
343
|
+
severity: "block",
|
|
344
|
+
keywords: ["github_pat_"]
|
|
625
345
|
},
|
|
346
|
+
// ── Slack ─────────────────────────────────────────────────────────────────
|
|
626
347
|
{
|
|
627
|
-
name: "
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
description: "The AI wants to delete files. Unlike moving to trash, rm is permanent \u2014 the files cannot be recovered without a backup."
|
|
348
|
+
name: "Slack Bot Token",
|
|
349
|
+
// Real tokens are ~50–80 chars; lower bound 20 avoids false negatives on partial tokens
|
|
350
|
+
regex: /\bxoxb-[0-9A-Za-z-]{20,100}\b/,
|
|
351
|
+
severity: "block",
|
|
352
|
+
keywords: ["xoxb-"]
|
|
633
353
|
},
|
|
634
|
-
// ──
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
// The postgres shield upgrades these from 'review' → 'block' for stricter teams;
|
|
638
|
-
// without a shield, users still get a human-approval gate on every destructive op.
|
|
354
|
+
// ── Anthropic ─────────────────────────────────────────────────────────────
|
|
355
|
+
// Listed before OpenAI — Anthropic keys start with sk-ant- which would also
|
|
356
|
+
// match the broader OpenAI sk- pattern; more specific rules must come first.
|
|
639
357
|
{
|
|
640
|
-
name: "
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead",
|
|
645
|
-
description: "The AI wants to drop a database table. This permanently deletes the table and all its data \u2014 there is no undo."
|
|
358
|
+
name: "Anthropic API Key",
|
|
359
|
+
regex: /\bsk-ant-api03-[a-zA-Z0-9_-]{93}AA\b/,
|
|
360
|
+
severity: "block",
|
|
361
|
+
keywords: ["sk-ant-api03"]
|
|
646
362
|
},
|
|
647
363
|
{
|
|
648
|
-
name: "
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead",
|
|
653
|
-
description: "The AI wants to truncate a database table, which instantly deletes every row. The table structure remains but all data is gone."
|
|
364
|
+
name: "Anthropic Admin Key",
|
|
365
|
+
regex: /\bsk-ant-admin01-[a-zA-Z0-9_-]{93}AA\b/,
|
|
366
|
+
severity: "block",
|
|
367
|
+
keywords: ["sk-ant-admin01"]
|
|
654
368
|
},
|
|
655
|
-
|
|
656
|
-
name: "review-drop-column-sql",
|
|
657
|
-
tool: "*",
|
|
658
|
-
conditions: [
|
|
659
|
-
{ field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
|
|
660
|
-
],
|
|
661
|
-
verdict: "review",
|
|
662
|
-
reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead",
|
|
663
|
-
description: "The AI wants to drop a column from a database table. This permanently removes the column and all its data from every row."
|
|
664
|
-
}
|
|
665
|
-
];
|
|
666
|
-
var cachedConfig = null;
|
|
667
|
-
function getCredentials() {
|
|
668
|
-
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
669
|
-
if (process.env.NODE9_API_KEY) {
|
|
670
|
-
return {
|
|
671
|
-
apiKey: process.env.NODE9_API_KEY,
|
|
672
|
-
apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
try {
|
|
676
|
-
const credPath = path3.join(os3.homedir(), ".node9", "credentials.json");
|
|
677
|
-
if (fs3.existsSync(credPath)) {
|
|
678
|
-
const creds = JSON.parse(fs3.readFileSync(credPath, "utf-8"));
|
|
679
|
-
const profileName = process.env.NODE9_PROFILE || "default";
|
|
680
|
-
const profile = creds[profileName];
|
|
681
|
-
if (profile?.apiKey) {
|
|
682
|
-
return {
|
|
683
|
-
apiKey: profile.apiKey,
|
|
684
|
-
apiUrl: profile.apiUrl || DEFAULT_API_URL
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
if (creds.apiKey) {
|
|
688
|
-
return {
|
|
689
|
-
apiKey: creds.apiKey,
|
|
690
|
-
apiUrl: creds.apiUrl || DEFAULT_API_URL
|
|
691
|
-
};
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
} catch {
|
|
695
|
-
}
|
|
696
|
-
return null;
|
|
697
|
-
}
|
|
698
|
-
function getActiveEnvironment(config) {
|
|
699
|
-
const env = config.settings.environment || process.env.NODE_ENV || "development";
|
|
700
|
-
return config.environments[env] ?? null;
|
|
701
|
-
}
|
|
702
|
-
function getConfig(cwd) {
|
|
703
|
-
if (!cwd && cachedConfig) return cachedConfig;
|
|
704
|
-
const globalPath = path3.join(os3.homedir(), ".node9", "config.json");
|
|
705
|
-
const projectPath = path3.join(cwd ?? process.cwd(), "node9.config.json");
|
|
706
|
-
const globalConfig = tryLoadConfig(globalPath);
|
|
707
|
-
const projectConfig = tryLoadConfig(projectPath);
|
|
708
|
-
const mergedSettings = {
|
|
709
|
-
...DEFAULT_CONFIG.settings,
|
|
710
|
-
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
711
|
-
};
|
|
712
|
-
const mergedPolicy = {
|
|
713
|
-
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
714
|
-
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
715
|
-
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
716
|
-
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
717
|
-
smartRules: [...DEFAULT_CONFIG.policy.smartRules],
|
|
718
|
-
snapshot: {
|
|
719
|
-
tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
|
|
720
|
-
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
721
|
-
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
722
|
-
},
|
|
723
|
-
dlp: { ...DEFAULT_CONFIG.policy.dlp },
|
|
724
|
-
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
|
|
725
|
-
skillPinning: {
|
|
726
|
-
...DEFAULT_CONFIG.policy.skillPinning,
|
|
727
|
-
roots: [...DEFAULT_CONFIG.policy.skillPinning.roots]
|
|
728
|
-
}
|
|
729
|
-
};
|
|
730
|
-
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
731
|
-
const applyLayer = (source) => {
|
|
732
|
-
if (!source) return;
|
|
733
|
-
const s = source.settings || {};
|
|
734
|
-
const p = source.policy || {};
|
|
735
|
-
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
736
|
-
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
737
|
-
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
738
|
-
if (s.enableHookLogDebug !== void 0)
|
|
739
|
-
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
740
|
-
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
741
|
-
if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
|
|
742
|
-
if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
|
|
743
|
-
mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
|
|
744
|
-
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
745
|
-
if (s.cloudSyncIntervalHours !== void 0)
|
|
746
|
-
mergedSettings.cloudSyncIntervalHours = s.cloudSyncIntervalHours;
|
|
747
|
-
if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
|
|
748
|
-
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
749
|
-
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
750
|
-
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
751
|
-
if (p.toolInspection)
|
|
752
|
-
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
753
|
-
if (p.smartRules) {
|
|
754
|
-
const defaultBlocks = mergedPolicy.smartRules.filter((r) => r.verdict === "block");
|
|
755
|
-
const defaultNonBlocks = mergedPolicy.smartRules.filter((r) => r.verdict !== "block");
|
|
756
|
-
const userRuleNames = new Set(p.smartRules.filter((r) => r.name).map((r) => r.name));
|
|
757
|
-
const filteredBlocks = defaultBlocks.filter((r) => !r.name || !userRuleNames.has(r.name));
|
|
758
|
-
const filteredNonBlocks = defaultNonBlocks.filter(
|
|
759
|
-
(r) => !r.name || !userRuleNames.has(r.name)
|
|
760
|
-
);
|
|
761
|
-
mergedPolicy.smartRules = [...filteredBlocks, ...p.smartRules, ...filteredNonBlocks];
|
|
762
|
-
}
|
|
763
|
-
if (p.snapshot) {
|
|
764
|
-
const s2 = p.snapshot;
|
|
765
|
-
if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
|
|
766
|
-
if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
|
|
767
|
-
if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
|
|
768
|
-
}
|
|
769
|
-
if (p.dlp) {
|
|
770
|
-
const d = p.dlp;
|
|
771
|
-
if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
|
|
772
|
-
if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
|
|
773
|
-
}
|
|
774
|
-
if (p.loopDetection) {
|
|
775
|
-
const ld = p.loopDetection;
|
|
776
|
-
if (ld.enabled !== void 0) mergedPolicy.loopDetection.enabled = ld.enabled;
|
|
777
|
-
if (ld.threshold !== void 0) mergedPolicy.loopDetection.threshold = ld.threshold;
|
|
778
|
-
if (ld.windowSeconds !== void 0)
|
|
779
|
-
mergedPolicy.loopDetection.windowSeconds = ld.windowSeconds;
|
|
780
|
-
}
|
|
781
|
-
if (p.skillPinning && typeof p.skillPinning === "object") {
|
|
782
|
-
const sp = p.skillPinning;
|
|
783
|
-
if (sp.enabled !== void 0) mergedPolicy.skillPinning.enabled = sp.enabled;
|
|
784
|
-
if (sp.mode !== void 0) mergedPolicy.skillPinning.mode = sp.mode;
|
|
785
|
-
if (Array.isArray(sp.roots)) {
|
|
786
|
-
for (const r of sp.roots) {
|
|
787
|
-
if (typeof r === "string" && r.length > 0) mergedPolicy.skillPinning.roots.push(r);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
const envs = source.environments || {};
|
|
792
|
-
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
793
|
-
if (envConfig && typeof envConfig === "object") {
|
|
794
|
-
const ec = envConfig;
|
|
795
|
-
mergedEnvironments[envName] = {
|
|
796
|
-
...mergedEnvironments[envName],
|
|
797
|
-
// Validate field types before merging — do not blindly spread user input
|
|
798
|
-
...typeof ec.requireApproval === "boolean" ? { requireApproval: ec.requireApproval } : {}
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
};
|
|
803
|
-
applyLayer(globalConfig);
|
|
804
|
-
applyLayer(projectConfig);
|
|
805
|
-
{
|
|
806
|
-
const cacheFile = path3.join(os3.homedir(), ".node9", "rules-cache.json");
|
|
807
|
-
try {
|
|
808
|
-
const raw = JSON.parse(fs3.readFileSync(cacheFile, "utf-8"));
|
|
809
|
-
if (Array.isArray(raw.rules) && raw.rules.length > 0) {
|
|
810
|
-
applyLayer({ policy: { smartRules: raw.rules } });
|
|
811
|
-
}
|
|
812
|
-
} catch {
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
const shieldOverrides = readShieldOverrides();
|
|
816
|
-
for (const shieldName of readActiveShields()) {
|
|
817
|
-
const shield = getShield(shieldName);
|
|
818
|
-
if (!shield) continue;
|
|
819
|
-
const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
820
|
-
const ruleOverrides = shieldOverrides[shieldName] ?? {};
|
|
821
|
-
for (const rule of shield.smartRules) {
|
|
822
|
-
if (!existingRuleNames.has(rule.name)) {
|
|
823
|
-
const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
|
|
824
|
-
mergedPolicy.smartRules.push(
|
|
825
|
-
overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
|
|
826
|
-
);
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
const existingWords = new Set(mergedPolicy.dangerousWords);
|
|
830
|
-
for (const word of shield.dangerousWords) {
|
|
831
|
-
if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
835
|
-
for (const rule of ADVISORY_SMART_RULES) {
|
|
836
|
-
if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
|
|
837
|
-
}
|
|
838
|
-
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
839
|
-
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
840
|
-
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
841
|
-
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
842
|
-
mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
|
|
843
|
-
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
844
|
-
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
845
|
-
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
846
|
-
const result = {
|
|
847
|
-
settings: mergedSettings,
|
|
848
|
-
policy: mergedPolicy,
|
|
849
|
-
environments: mergedEnvironments
|
|
850
|
-
};
|
|
851
|
-
if (!cwd) cachedConfig = result;
|
|
852
|
-
return result;
|
|
853
|
-
}
|
|
854
|
-
function tryLoadConfig(filePath) {
|
|
855
|
-
if (!fs3.existsSync(filePath)) return null;
|
|
856
|
-
let raw;
|
|
857
|
-
try {
|
|
858
|
-
raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
859
|
-
} catch (err) {
|
|
860
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
861
|
-
process.stderr.write(
|
|
862
|
-
`
|
|
863
|
-
\u26A0\uFE0F Node9: Failed to parse ${filePath}
|
|
864
|
-
${msg}
|
|
865
|
-
\u2192 Using default config
|
|
866
|
-
|
|
867
|
-
`
|
|
868
|
-
);
|
|
869
|
-
return null;
|
|
870
|
-
}
|
|
871
|
-
const SUPPORTED_VERSION = "1.0";
|
|
872
|
-
const SUPPORTED_MAJOR = SUPPORTED_VERSION.split(".")[0];
|
|
873
|
-
const fileVersion = raw?.version;
|
|
874
|
-
if (fileVersion !== void 0) {
|
|
875
|
-
const vStr = String(fileVersion);
|
|
876
|
-
const fileMajor = vStr.split(".")[0];
|
|
877
|
-
if (fileMajor !== SUPPORTED_MAJOR) {
|
|
878
|
-
process.stderr.write(
|
|
879
|
-
`
|
|
880
|
-
\u274C Node9: Config at ${filePath} has version "${vStr}" \u2014 major version is incompatible with this release (expected "${SUPPORTED_VERSION}"). Config will not be loaded.
|
|
881
|
-
|
|
882
|
-
`
|
|
883
|
-
);
|
|
884
|
-
return null;
|
|
885
|
-
} else if (vStr !== SUPPORTED_VERSION) {
|
|
886
|
-
process.stderr.write(
|
|
887
|
-
`
|
|
888
|
-
\u26A0\uFE0F Node9: Config at ${filePath} declares version "${vStr}" \u2014 expected "${SUPPORTED_VERSION}". Continuing with best-effort parsing.
|
|
889
|
-
|
|
890
|
-
`
|
|
891
|
-
);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
const { sanitized, error } = sanitizeConfig(raw);
|
|
895
|
-
if (error) {
|
|
896
|
-
process.stderr.write(
|
|
897
|
-
`
|
|
898
|
-
\u26A0\uFE0F Node9: Invalid config at ${filePath}:
|
|
899
|
-
${error.replace("Invalid config:\n", "")}
|
|
900
|
-
\u2192 Invalid fields ignored, using defaults for those keys
|
|
901
|
-
|
|
902
|
-
`
|
|
903
|
-
);
|
|
904
|
-
}
|
|
905
|
-
return sanitized;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// src/utils/regex.ts
|
|
909
|
-
import safeRegex from "safe-regex2";
|
|
910
|
-
var MAX_REGEX_LENGTH = 100;
|
|
911
|
-
var REGEX_CACHE_MAX = 500;
|
|
912
|
-
var regexCache = /* @__PURE__ */ new Map();
|
|
913
|
-
function validateRegex(pattern) {
|
|
914
|
-
if (!pattern) return "Pattern is required";
|
|
915
|
-
if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
|
|
916
|
-
try {
|
|
917
|
-
new RegExp(pattern);
|
|
918
|
-
} catch (e) {
|
|
919
|
-
return `Invalid regex syntax: ${e.message}`;
|
|
920
|
-
}
|
|
921
|
-
if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
|
|
922
|
-
if (!safeRegex(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
|
|
923
|
-
return null;
|
|
924
|
-
}
|
|
925
|
-
function getCompiledRegex(pattern, flags = "") {
|
|
926
|
-
if (flags && !/^[gimsuy]+$/.test(flags)) {
|
|
927
|
-
if (process.env.NODE9_DEBUG === "1") console.error(`[Node9] Invalid regex flags: "${flags}"`);
|
|
928
|
-
return null;
|
|
929
|
-
}
|
|
930
|
-
const key = `${pattern}\0${flags}`;
|
|
931
|
-
if (regexCache.has(key)) {
|
|
932
|
-
const cached = regexCache.get(key);
|
|
933
|
-
regexCache.delete(key);
|
|
934
|
-
regexCache.set(key, cached);
|
|
935
|
-
return cached;
|
|
936
|
-
}
|
|
937
|
-
const err = validateRegex(pattern);
|
|
938
|
-
if (err) {
|
|
939
|
-
if (process.env.NODE9_DEBUG === "1")
|
|
940
|
-
console.error(`[Node9] Regex blocked: ${err} \u2014 pattern: "${pattern}"`);
|
|
941
|
-
return null;
|
|
942
|
-
}
|
|
943
|
-
try {
|
|
944
|
-
const re = new RegExp(pattern, flags);
|
|
945
|
-
if (regexCache.size >= REGEX_CACHE_MAX) {
|
|
946
|
-
const oldest = regexCache.keys().next().value;
|
|
947
|
-
if (oldest) regexCache.delete(oldest);
|
|
948
|
-
}
|
|
949
|
-
regexCache.set(key, re);
|
|
950
|
-
return re;
|
|
951
|
-
} catch (e) {
|
|
952
|
-
if (process.env.NODE9_DEBUG === "1") console.error(`[Node9] Regex compile failed:`, e);
|
|
953
|
-
return null;
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// src/policy/index.ts
|
|
958
|
-
import path8 from "path";
|
|
959
|
-
import pm from "picomatch";
|
|
960
|
-
import mvdanSh from "mvdan-sh";
|
|
961
|
-
|
|
962
|
-
// src/dlp.ts
|
|
963
|
-
import fs4 from "fs";
|
|
964
|
-
import path4 from "path";
|
|
965
|
-
var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
|
|
966
|
-
function isAssignmentContext(text) {
|
|
967
|
-
return ASSIGNMENT_CONTEXT_RE.test(text);
|
|
968
|
-
}
|
|
969
|
-
function shannonEntropy(s) {
|
|
970
|
-
if (s.length === 0) return 0;
|
|
971
|
-
const freq = /* @__PURE__ */ new Map();
|
|
972
|
-
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
973
|
-
let h = 0;
|
|
974
|
-
for (const count of freq.values()) {
|
|
975
|
-
const p = count / s.length;
|
|
976
|
-
h -= p * Math.log2(p);
|
|
977
|
-
}
|
|
978
|
-
return h;
|
|
979
|
-
}
|
|
980
|
-
var DLP_STOPWORDS = [
|
|
981
|
-
"example",
|
|
982
|
-
"placeholder",
|
|
983
|
-
"changeme",
|
|
984
|
-
"your_key",
|
|
985
|
-
"your_token",
|
|
986
|
-
"your_secret",
|
|
987
|
-
"replace_me",
|
|
988
|
-
"insert_key",
|
|
989
|
-
"put_your",
|
|
990
|
-
"fake",
|
|
991
|
-
"dummy",
|
|
992
|
-
"sample",
|
|
993
|
-
"xxxxxxxx",
|
|
994
|
-
"aaaaaa",
|
|
995
|
-
"bbbbbb",
|
|
996
|
-
"00000000",
|
|
997
|
-
"${",
|
|
998
|
-
"{{",
|
|
999
|
-
"%{",
|
|
1000
|
-
"<your",
|
|
1001
|
-
"test_key",
|
|
1002
|
-
"test_token",
|
|
1003
|
-
"your",
|
|
1004
|
-
"here"
|
|
1005
|
-
];
|
|
1006
|
-
var DLP_PATTERNS = [
|
|
1007
|
-
// ── AWS ───────────────────────────────────────────────────────────────────
|
|
1008
|
-
{
|
|
1009
|
-
name: "AWS Access Key ID",
|
|
1010
|
-
regex: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/,
|
|
1011
|
-
severity: "block",
|
|
1012
|
-
keywords: ["akia", "asia", "abia", "acca", "a3t"]
|
|
1013
|
-
},
|
|
1014
|
-
// ── GitHub ────────────────────────────────────────────────────────────────
|
|
1015
|
-
{
|
|
1016
|
-
name: "GitHub Token",
|
|
1017
|
-
regex: /\bgh[pous]_[A-Za-z0-9]{36}\b/,
|
|
1018
|
-
severity: "block",
|
|
1019
|
-
keywords: ["ghp_", "gho_", "ghu_", "ghs_"],
|
|
1020
|
-
minEntropy: 3
|
|
1021
|
-
},
|
|
1022
|
-
{
|
|
1023
|
-
name: "GitHub Fine-Grained PAT",
|
|
1024
|
-
regex: /\bgithub_pat_\w{82}\b/,
|
|
1025
|
-
severity: "block",
|
|
1026
|
-
keywords: ["github_pat_"]
|
|
1027
|
-
},
|
|
1028
|
-
// ── Slack ─────────────────────────────────────────────────────────────────
|
|
1029
|
-
{
|
|
1030
|
-
name: "Slack Bot Token",
|
|
1031
|
-
// Real tokens are ~50–80 chars; lower bound 20 avoids false negatives on partial tokens
|
|
1032
|
-
regex: /\bxoxb-[0-9A-Za-z-]{20,100}\b/,
|
|
1033
|
-
severity: "block",
|
|
1034
|
-
keywords: ["xoxb-"]
|
|
1035
|
-
},
|
|
1036
|
-
// ── Anthropic ─────────────────────────────────────────────────────────────
|
|
1037
|
-
// Listed before OpenAI — Anthropic keys start with sk-ant- which would also
|
|
1038
|
-
// match the broader OpenAI sk- pattern; more specific rules must come first.
|
|
1039
|
-
{
|
|
1040
|
-
name: "Anthropic API Key",
|
|
1041
|
-
regex: /\bsk-ant-api03-[a-zA-Z0-9_-]{93}AA\b/,
|
|
1042
|
-
severity: "block",
|
|
1043
|
-
keywords: ["sk-ant-api03"]
|
|
1044
|
-
},
|
|
1045
|
-
{
|
|
1046
|
-
name: "Anthropic Admin Key",
|
|
1047
|
-
regex: /\bsk-ant-admin01-[a-zA-Z0-9_-]{93}AA\b/,
|
|
1048
|
-
severity: "block",
|
|
1049
|
-
keywords: ["sk-ant-admin01"]
|
|
1050
|
-
},
|
|
1051
|
-
// ── OpenAI ────────────────────────────────────────────────────────────────
|
|
369
|
+
// ── OpenAI ────────────────────────────────────────────────────────────────
|
|
1052
370
|
{
|
|
1053
371
|
name: "OpenAI API Key",
|
|
1054
372
|
regex: /\bsk-[a-zA-Z0-9_-]{20,}\b/,
|
|
@@ -1422,1014 +740,2450 @@ var SENSITIVE_PATH_PATTERNS = [
|
|
|
1422
740
|
/[/\\]id_ed25519$/i,
|
|
1423
741
|
/[/\\]id_ecdsa$/i
|
|
1424
742
|
];
|
|
1425
|
-
function
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
const absolute = path4.resolve(cwd, filePath);
|
|
1430
|
-
resolved = fs4.realpathSync.native(absolute);
|
|
1431
|
-
} catch (err) {
|
|
1432
|
-
const code = err.code;
|
|
1433
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
1434
|
-
resolved = path4.resolve(cwd, filePath);
|
|
1435
|
-
} else {
|
|
743
|
+
function matchSensitivePath(resolvedPath, originalPath) {
|
|
744
|
+
const normalised = resolvedPath.replace(/\\/g, "/");
|
|
745
|
+
for (const pattern of SENSITIVE_PATH_PATTERNS) {
|
|
746
|
+
if (pattern.test(normalised)) {
|
|
1436
747
|
return {
|
|
1437
748
|
patternName: "Sensitive File Path",
|
|
1438
749
|
fieldPath: "file_path",
|
|
1439
|
-
redactedSample:
|
|
750
|
+
redactedSample: originalPath,
|
|
1440
751
|
severity: "block"
|
|
1441
752
|
};
|
|
1442
753
|
}
|
|
1443
754
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
function sensitivePathMatch(originalPath) {
|
|
758
|
+
return {
|
|
759
|
+
patternName: "Sensitive File Path",
|
|
760
|
+
fieldPath: "file_path",
|
|
761
|
+
redactedSample: originalPath,
|
|
762
|
+
severity: "block"
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function assertBuiltinPatternsAreSafe() {
|
|
766
|
+
for (const p of DLP_PATTERNS) {
|
|
767
|
+
if (!safeRegex(p.regex.source)) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`[node9 engine] Builtin DLP pattern '${p.name}' is vulnerable to ReDoS: ${p.regex.source}`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
for (const re of SENSITIVE_PATH_PATTERNS) {
|
|
774
|
+
if (!safeRegex(re.source)) {
|
|
775
|
+
throw new Error(
|
|
776
|
+
`[node9 engine] Builtin sensitive-path pattern is vulnerable to ReDoS: ${re.source}`
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
assertBuiltinPatternsAreSafe();
|
|
782
|
+
function maskSecret(raw, pattern) {
|
|
783
|
+
const match = raw.match(pattern);
|
|
784
|
+
if (!match) return "****";
|
|
785
|
+
const secret = match[0];
|
|
786
|
+
if (secret.length < 8) return "****";
|
|
787
|
+
const prefix = secret.slice(0, 4);
|
|
788
|
+
const suffix = secret.slice(-4);
|
|
789
|
+
const stars = "*".repeat(Math.min(secret.length - 8, 12));
|
|
790
|
+
return `${prefix}${stars}${suffix}`;
|
|
791
|
+
}
|
|
792
|
+
var MAX_DEPTH = 5;
|
|
793
|
+
var MAX_STRING_BYTES = 1e5;
|
|
794
|
+
var MAX_JSON_PARSE_BYTES = 1e4;
|
|
795
|
+
function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
796
|
+
if (depth > MAX_DEPTH || args === null || args === void 0) return null;
|
|
797
|
+
if (Array.isArray(args)) {
|
|
798
|
+
for (let i = 0; i < args.length; i++) {
|
|
799
|
+
const match = scanArgs(args[i], depth + 1, `${fieldPath}[${i}]`);
|
|
800
|
+
if (match) return match;
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
if (typeof args === "object") {
|
|
805
|
+
for (const [key, value] of Object.entries(args)) {
|
|
806
|
+
const match = scanArgs(value, depth + 1, `${fieldPath}.${key}`);
|
|
807
|
+
if (match) return match;
|
|
808
|
+
}
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
if (typeof args === "string") {
|
|
812
|
+
const text = args.length > MAX_STRING_BYTES ? args.slice(0, MAX_STRING_BYTES) : args;
|
|
813
|
+
const textLower = text.toLowerCase();
|
|
814
|
+
const assignmentCtx = isAssignmentContext(text);
|
|
815
|
+
for (const pattern of DLP_PATTERNS) {
|
|
816
|
+
if (pattern.keywords && !pattern.keywords.some((kw) => textLower.includes(kw.toLowerCase()))) {
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (pattern.regex.test(text)) {
|
|
820
|
+
const raw = text.match(pattern.regex)?.[0] ?? "";
|
|
821
|
+
if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) continue;
|
|
822
|
+
if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) continue;
|
|
823
|
+
const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
|
|
824
|
+
return {
|
|
825
|
+
patternName: pattern.name,
|
|
826
|
+
fieldPath,
|
|
827
|
+
redactedSample: maskSecret(text, pattern.regex),
|
|
828
|
+
severity
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (text.length < MAX_JSON_PARSE_BYTES) {
|
|
833
|
+
const trimmed = text.trim();
|
|
834
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
835
|
+
try {
|
|
836
|
+
const parsed = JSON.parse(text);
|
|
837
|
+
const inner = scanArgs(parsed, depth + 1, fieldPath);
|
|
838
|
+
if (inner) return inner;
|
|
839
|
+
} catch {
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
var { syntax } = mvdanSh;
|
|
847
|
+
var sharedParser = syntax.NewParser();
|
|
848
|
+
var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
|
|
849
|
+
"-m",
|
|
850
|
+
"--message",
|
|
851
|
+
"--body",
|
|
852
|
+
"--title",
|
|
853
|
+
"--description",
|
|
854
|
+
"--comment",
|
|
855
|
+
"--subject",
|
|
856
|
+
"--summary"
|
|
857
|
+
]);
|
|
858
|
+
var SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
|
|
859
|
+
var DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
|
|
860
|
+
function normalizeCommandForPolicy(command) {
|
|
861
|
+
try {
|
|
862
|
+
const f = sharedParser.Parse(command, "cmd");
|
|
863
|
+
const strips = [];
|
|
864
|
+
syntax.Walk(f, (node) => {
|
|
865
|
+
if (!node) return false;
|
|
866
|
+
const n = node;
|
|
867
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
868
|
+
const args = n.Args || [];
|
|
869
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
870
|
+
const argParts = args[i].Parts || [];
|
|
871
|
+
if (argParts.length !== 1 || syntax.NodeType(argParts[0]) !== "Lit") continue;
|
|
872
|
+
const flagVal = argParts[0].Value || "";
|
|
873
|
+
if (!MESSAGE_FLAGS.has(flagVal.toLowerCase())) continue;
|
|
874
|
+
const next = args[i + 1];
|
|
875
|
+
const nextParts = next.Parts || [];
|
|
876
|
+
if (nextParts.length !== 1) continue;
|
|
877
|
+
const quotedNode = nextParts[0];
|
|
878
|
+
const nt = syntax.NodeType(quotedNode);
|
|
879
|
+
if (nt === "SglQuoted") {
|
|
880
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
881
|
+
} else if (nt === "DblQuoted") {
|
|
882
|
+
const innerParts = quotedNode.Parts || [];
|
|
883
|
+
const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
|
|
884
|
+
if (allLit) strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return true;
|
|
888
|
+
});
|
|
889
|
+
if (strips.length === 0) return command;
|
|
890
|
+
strips.sort((a, b) => b[0] - a[0]);
|
|
891
|
+
let result = command;
|
|
892
|
+
for (const [start, end] of strips) {
|
|
893
|
+
result = result.slice(0, start) + '""' + result.slice(end);
|
|
1454
894
|
}
|
|
895
|
+
return result;
|
|
896
|
+
} catch {
|
|
897
|
+
return command;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
function scanArgsForDynamicExec(args, startIdx) {
|
|
901
|
+
let hasCmdSubst = false;
|
|
902
|
+
let hasParamExp = false;
|
|
903
|
+
let hasCurl = false;
|
|
904
|
+
for (let i = startIdx; i < args.length; i++) {
|
|
905
|
+
syntax.Walk(args[i], (inner) => {
|
|
906
|
+
if (!inner) return false;
|
|
907
|
+
const inn = inner;
|
|
908
|
+
const it = syntax.NodeType(inn);
|
|
909
|
+
if (it === "CmdSubst") hasCmdSubst = true;
|
|
910
|
+
if (it === "ParamExp") hasParamExp = true;
|
|
911
|
+
if (it === "Lit" && DOWNLOAD_CMDS.has(inn.Value?.toLowerCase())) hasCurl = true;
|
|
912
|
+
return true;
|
|
913
|
+
});
|
|
1455
914
|
}
|
|
915
|
+
if (hasCmdSubst && hasCurl) return "block";
|
|
916
|
+
if (hasCmdSubst || hasParamExp) return "review";
|
|
1456
917
|
return null;
|
|
1457
918
|
}
|
|
1458
|
-
function
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
919
|
+
function detectDangerousShellExec(command) {
|
|
920
|
+
try {
|
|
921
|
+
const f = sharedParser.Parse(command, "cmd");
|
|
922
|
+
let result = null;
|
|
923
|
+
syntax.Walk(f, (node) => {
|
|
924
|
+
if (!node || result === "block") return false;
|
|
925
|
+
const n = node;
|
|
926
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
927
|
+
const args = n.Args || [];
|
|
928
|
+
if (args.length === 0) return true;
|
|
929
|
+
const firstParts = args[0].Parts || [];
|
|
930
|
+
if (firstParts.length !== 1 || syntax.NodeType(firstParts[0]) !== "Lit") return true;
|
|
931
|
+
const cmdName = firstParts[0].Value?.toLowerCase() ?? "";
|
|
932
|
+
if (cmdName === "eval") {
|
|
933
|
+
const v = scanArgsForDynamicExec(args, 1);
|
|
934
|
+
if (v === "block" || v === "review" && result === null) result = v;
|
|
935
|
+
} else if (SHELL_INTERPRETERS.has(cmdName)) {
|
|
936
|
+
for (let i = 1; i < args.length - 1; i++) {
|
|
937
|
+
const flagParts = args[i].Parts || [];
|
|
938
|
+
if (flagParts.length !== 1 || syntax.NodeType(flagParts[0]) !== "Lit" || flagParts[0].Value !== "-c")
|
|
939
|
+
continue;
|
|
940
|
+
const v = scanArgsForDynamicExec(args, i + 1);
|
|
941
|
+
if (v === "block" || v === "review" && result === null) result = v;
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return true;
|
|
946
|
+
});
|
|
947
|
+
return result;
|
|
948
|
+
} catch {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
1467
951
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
952
|
+
function analyzeShellCommand(command) {
|
|
953
|
+
const actions = [];
|
|
954
|
+
const paths = [];
|
|
955
|
+
const allTokens = [];
|
|
956
|
+
const addToken = (token) => {
|
|
957
|
+
const lower = token.toLowerCase();
|
|
958
|
+
allTokens.push(lower);
|
|
959
|
+
if (lower.includes("/")) allTokens.push(...lower.split("/").filter(Boolean));
|
|
960
|
+
if (lower.startsWith("-")) allTokens.push(lower.replace(/^-+/, ""));
|
|
961
|
+
};
|
|
962
|
+
try {
|
|
963
|
+
const f = sharedParser.Parse(command, "cmd");
|
|
964
|
+
syntax.Walk(f, (node) => {
|
|
965
|
+
if (!node) return false;
|
|
966
|
+
const n = node;
|
|
967
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
968
|
+
const wordValues = (n.Args || []).map((arg) => {
|
|
969
|
+
return (arg.Parts || []).map((p) => (p.Value ?? "").replace(/\\(.)/g, "$1")).join("");
|
|
970
|
+
}).filter((s) => s.length > 0);
|
|
971
|
+
if (wordValues.length > 0) {
|
|
972
|
+
const cmd = wordValues[0].toLowerCase();
|
|
973
|
+
if (!actions.includes(cmd)) actions.push(cmd);
|
|
974
|
+
wordValues.forEach((w) => addToken(w));
|
|
975
|
+
wordValues.slice(1).forEach((w) => {
|
|
976
|
+
if (!w.startsWith("-")) paths.push(w);
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
return true;
|
|
980
|
+
});
|
|
981
|
+
} catch {
|
|
982
|
+
}
|
|
983
|
+
if (allTokens.length === 0) {
|
|
984
|
+
const normalized = command.replace(/\\(.)/g, "$1");
|
|
985
|
+
const sanitized = normalized.replace(/["'<>]/g, " ");
|
|
986
|
+
const segments = sanitized.split(/[|;&]|\$\(|\)|`/);
|
|
987
|
+
segments.forEach((segment) => {
|
|
988
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
989
|
+
if (tokens.length > 0) {
|
|
990
|
+
const action = tokens[0].toLowerCase();
|
|
991
|
+
if (!actions.includes(action)) actions.push(action);
|
|
992
|
+
tokens.forEach((t) => {
|
|
993
|
+
addToken(t);
|
|
994
|
+
if (t !== tokens[0] && !t.startsWith("-")) {
|
|
995
|
+
if (!paths.includes(t)) paths.push(t);
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
return { actions, paths, allTokens };
|
|
1002
|
+
}
|
|
1003
|
+
var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
|
|
1004
|
+
"cat",
|
|
1005
|
+
"head",
|
|
1006
|
+
"tail",
|
|
1007
|
+
"grep",
|
|
1008
|
+
"awk",
|
|
1009
|
+
"sed",
|
|
1010
|
+
"cut",
|
|
1011
|
+
"sort",
|
|
1012
|
+
"tee",
|
|
1013
|
+
"less",
|
|
1014
|
+
"more",
|
|
1015
|
+
"strings",
|
|
1016
|
+
"xxd"
|
|
1017
|
+
]);
|
|
1018
|
+
var SINK_COMMANDS = /* @__PURE__ */ new Set([
|
|
1019
|
+
"curl",
|
|
1020
|
+
"wget",
|
|
1021
|
+
"nc",
|
|
1022
|
+
"ncat",
|
|
1023
|
+
"netcat",
|
|
1024
|
+
"ssh",
|
|
1025
|
+
"scp",
|
|
1026
|
+
"rsync",
|
|
1027
|
+
"socat",
|
|
1028
|
+
"ftp",
|
|
1029
|
+
"sftp",
|
|
1030
|
+
"telnet"
|
|
1031
|
+
]);
|
|
1032
|
+
var OBFUSCATORS = /* @__PURE__ */ new Set([
|
|
1033
|
+
"base64",
|
|
1034
|
+
"gzip",
|
|
1035
|
+
"gunzip",
|
|
1036
|
+
"bzip2",
|
|
1037
|
+
"xz",
|
|
1038
|
+
"zstd",
|
|
1039
|
+
"openssl",
|
|
1040
|
+
"gpg",
|
|
1041
|
+
"python",
|
|
1042
|
+
"python3",
|
|
1043
|
+
"perl",
|
|
1044
|
+
"ruby",
|
|
1045
|
+
"node"
|
|
1046
|
+
]);
|
|
1047
|
+
var SENSITIVE_PATTERNS = [
|
|
1048
|
+
/(?:^|\/)\.env(?:\.|$)/i,
|
|
1049
|
+
// .env, .env.local, .env.production
|
|
1050
|
+
/id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
|
|
1051
|
+
// SSH private keys
|
|
1052
|
+
/\.pem$|\.key$|\.p12$|\.pfx$/i,
|
|
1053
|
+
// certificate files
|
|
1054
|
+
/(?:^|\/)\.ssh\//i,
|
|
1055
|
+
// ~/.ssh/ directory
|
|
1056
|
+
/(?:^|\/)\.aws\/credentials/i,
|
|
1057
|
+
// AWS credentials
|
|
1058
|
+
/(?:^|\/)\.netrc$/i,
|
|
1059
|
+
// netrc (stores HTTP credentials)
|
|
1060
|
+
/(?:^|\/)(passwd|shadow|sudoers)$/i,
|
|
1061
|
+
// /etc/passwd, /etc/shadow
|
|
1062
|
+
/(?:^|\/)credentials(?:\.json)?$/i
|
|
1063
|
+
// generic credentials files
|
|
1064
|
+
];
|
|
1065
|
+
function isSensitivePath(p) {
|
|
1066
|
+
return SENSITIVE_PATTERNS.some((re) => re.test(p));
|
|
1067
|
+
}
|
|
1068
|
+
function splitOnPipe(cmd) {
|
|
1069
|
+
const segments = [];
|
|
1070
|
+
let current = "";
|
|
1071
|
+
let inSingle = false;
|
|
1072
|
+
let inDouble = false;
|
|
1073
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
1074
|
+
const ch = cmd[i];
|
|
1075
|
+
if (ch === "'" && !inDouble) {
|
|
1076
|
+
inSingle = !inSingle;
|
|
1077
|
+
current += ch;
|
|
1078
|
+
} else if (ch === '"' && !inSingle) {
|
|
1079
|
+
inDouble = !inDouble;
|
|
1080
|
+
current += ch;
|
|
1081
|
+
} else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
|
|
1082
|
+
segments.push(current.trim());
|
|
1083
|
+
current = "";
|
|
1084
|
+
} else {
|
|
1085
|
+
current += ch;
|
|
1477
1086
|
}
|
|
1478
|
-
return null;
|
|
1479
1087
|
}
|
|
1480
|
-
if (
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1088
|
+
if (current.trim()) segments.push(current.trim());
|
|
1089
|
+
return segments.filter(Boolean);
|
|
1090
|
+
}
|
|
1091
|
+
function positionalTokens(segment) {
|
|
1092
|
+
return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
|
|
1093
|
+
}
|
|
1094
|
+
function analyzePipeChain(command) {
|
|
1095
|
+
const segments = splitOnPipe(command);
|
|
1096
|
+
if (segments.length < 2) {
|
|
1097
|
+
return {
|
|
1098
|
+
isPipeline: false,
|
|
1099
|
+
hasSensitiveSource: false,
|
|
1100
|
+
hasExternalSink: false,
|
|
1101
|
+
hasObfuscation: false,
|
|
1102
|
+
sourceFiles: [],
|
|
1103
|
+
sinkTargets: [],
|
|
1104
|
+
risk: "none"
|
|
1105
|
+
};
|
|
1486
1106
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
return {
|
|
1501
|
-
patternName: pattern.name,
|
|
1502
|
-
fieldPath,
|
|
1503
|
-
redactedSample: maskSecret(text, pattern.regex),
|
|
1504
|
-
severity
|
|
1505
|
-
};
|
|
1506
|
-
}
|
|
1107
|
+
const sourceFiles = [];
|
|
1108
|
+
const sinkTargets = [];
|
|
1109
|
+
let hasSensitiveSource = false;
|
|
1110
|
+
let hasExternalSink = false;
|
|
1111
|
+
let hasObfuscation = false;
|
|
1112
|
+
for (const segment of segments) {
|
|
1113
|
+
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
1114
|
+
if (tokens.length === 0) continue;
|
|
1115
|
+
const binary = tokens[0].toLowerCase();
|
|
1116
|
+
const args = positionalTokens(segment);
|
|
1117
|
+
if (SOURCE_COMMANDS.has(binary)) {
|
|
1118
|
+
sourceFiles.push(...args);
|
|
1119
|
+
if (args.some(isSensitivePath)) hasSensitiveSource = true;
|
|
1507
1120
|
}
|
|
1508
|
-
if (
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
} catch {
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1121
|
+
if (OBFUSCATORS.has(binary)) hasObfuscation = true;
|
|
1122
|
+
if (SINK_COMMANDS.has(binary)) {
|
|
1123
|
+
const targets = args.filter(
|
|
1124
|
+
(a) => a.includes(".") || a.includes("://") || /^\d+\.\d+/.test(a)
|
|
1125
|
+
);
|
|
1126
|
+
sinkTargets.push(...targets);
|
|
1127
|
+
if (targets.length > 0) hasExternalSink = true;
|
|
1518
1128
|
}
|
|
1519
1129
|
}
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
import os4 from "os";
|
|
1527
|
-
var SYSTEM_PREFIXES = ["/usr/bin", "/usr/sbin", "/bin", "/sbin"];
|
|
1528
|
-
var MANAGED_PREFIXES = ["/usr/local/bin", "/opt/homebrew", "/home/linuxbrew", "/nix/store"];
|
|
1529
|
-
var USER_PREFIXES = [
|
|
1530
|
-
path5.join(os4.homedir(), "bin"),
|
|
1531
|
-
path5.join(os4.homedir(), ".local", "bin"),
|
|
1532
|
-
path5.join(os4.homedir(), ".cargo", "bin"),
|
|
1533
|
-
path5.join(os4.homedir(), ".npm-global", "bin"),
|
|
1534
|
-
path5.join(os4.homedir(), ".volta", "bin")
|
|
1535
|
-
];
|
|
1536
|
-
var SUSPECT_PREFIXES = ["/tmp", "/var/tmp", "/dev/shm"];
|
|
1537
|
-
function findInPath(cmd) {
|
|
1538
|
-
if (path5.posix.isAbsolute(cmd)) return cmd;
|
|
1539
|
-
const pathEnv = process.env.PATH ?? "";
|
|
1540
|
-
for (const dir of pathEnv.split(path5.delimiter)) {
|
|
1541
|
-
if (!dir) continue;
|
|
1542
|
-
const full = path5.join(dir, cmd);
|
|
1543
|
-
try {
|
|
1544
|
-
fs5.accessSync(full, fs5.constants.X_OK);
|
|
1545
|
-
return full;
|
|
1546
|
-
} catch {
|
|
1130
|
+
const fullCmd = command.toLowerCase();
|
|
1131
|
+
if (!hasSensitiveSource) {
|
|
1132
|
+
const redirMatch = fullCmd.match(/<\s*(\S+)/);
|
|
1133
|
+
if (redirMatch && isSensitivePath(redirMatch[1])) {
|
|
1134
|
+
hasSensitiveSource = true;
|
|
1135
|
+
sourceFiles.push(redirMatch[1]);
|
|
1547
1136
|
}
|
|
1548
1137
|
}
|
|
1549
|
-
|
|
1138
|
+
const risk = hasSensitiveSource && hasExternalSink && hasObfuscation ? "critical" : hasSensitiveSource && hasExternalSink ? "high" : hasExternalSink ? "medium" : "none";
|
|
1139
|
+
return {
|
|
1140
|
+
isPipeline: true,
|
|
1141
|
+
hasSensitiveSource,
|
|
1142
|
+
hasExternalSink,
|
|
1143
|
+
hasObfuscation,
|
|
1144
|
+
sourceFiles,
|
|
1145
|
+
sinkTargets,
|
|
1146
|
+
risk
|
|
1147
|
+
};
|
|
1550
1148
|
}
|
|
1551
|
-
function
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
}
|
|
1555
|
-
const osTmp = os4.tmpdir();
|
|
1556
|
-
const allSuspect = osTmp ? [...SUSPECT_PREFIXES, osTmp] : SUSPECT_PREFIXES;
|
|
1557
|
-
if (allSuspect.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
|
|
1558
|
-
return { trustLevel: "suspect", reason: `binary in temp directory: ${resolved}` };
|
|
1559
|
-
}
|
|
1560
|
-
if (SYSTEM_PREFIXES.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
|
|
1561
|
-
return { trustLevel: "system", reason: "" };
|
|
1562
|
-
}
|
|
1563
|
-
if (MANAGED_PREFIXES.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
|
|
1564
|
-
return { trustLevel: "managed", reason: "" };
|
|
1565
|
-
}
|
|
1566
|
-
if (USER_PREFIXES.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
|
|
1567
|
-
return { trustLevel: "user", reason: "" };
|
|
1568
|
-
}
|
|
1569
|
-
return { trustLevel: "unknown", reason: "binary in unrecognized location" };
|
|
1149
|
+
function basename(p) {
|
|
1150
|
+
const segments = p.split(/[\\/]/);
|
|
1151
|
+
return segments[segments.length - 1] || "";
|
|
1570
1152
|
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1153
|
+
var FLAGS_WITH_VALUES = {
|
|
1154
|
+
curl: /* @__PURE__ */ new Set([
|
|
1155
|
+
"-H",
|
|
1156
|
+
"--header",
|
|
1157
|
+
"-A",
|
|
1158
|
+
"--user-agent",
|
|
1159
|
+
"-e",
|
|
1160
|
+
"--referer",
|
|
1161
|
+
"-x",
|
|
1162
|
+
"--proxy",
|
|
1163
|
+
"-u",
|
|
1164
|
+
"--user",
|
|
1165
|
+
"-d",
|
|
1166
|
+
"--data",
|
|
1167
|
+
"--data-raw",
|
|
1168
|
+
"--data-binary",
|
|
1169
|
+
"-o",
|
|
1170
|
+
"--output",
|
|
1171
|
+
"-F",
|
|
1172
|
+
"--form",
|
|
1173
|
+
"--connect-to",
|
|
1174
|
+
"--resolve",
|
|
1175
|
+
"--cacert",
|
|
1176
|
+
"--cert",
|
|
1177
|
+
"--key",
|
|
1178
|
+
"-m",
|
|
1179
|
+
"--max-time"
|
|
1180
|
+
]),
|
|
1181
|
+
wget: /* @__PURE__ */ new Set([
|
|
1182
|
+
"-O",
|
|
1183
|
+
"--output-document",
|
|
1184
|
+
"-P",
|
|
1185
|
+
"--directory-prefix",
|
|
1186
|
+
"-U",
|
|
1187
|
+
"--user-agent",
|
|
1188
|
+
"-e",
|
|
1189
|
+
"--execute",
|
|
1190
|
+
"--proxy",
|
|
1191
|
+
"--ca-certificate"
|
|
1192
|
+
]),
|
|
1193
|
+
nc: /* @__PURE__ */ new Set(["-x", "-p", "-s", "-w", "-W", "-I", "-O"]),
|
|
1194
|
+
ncat: /* @__PURE__ */ new Set(["-x", "-p", "-s", "--proxy", "--proxy-auth", "-w", "--wait"]),
|
|
1195
|
+
netcat: /* @__PURE__ */ new Set(["-x", "-p", "-s", "-w"]),
|
|
1196
|
+
ssh: /* @__PURE__ */ new Set([
|
|
1197
|
+
"-i",
|
|
1198
|
+
"-l",
|
|
1199
|
+
"-p",
|
|
1200
|
+
"-o",
|
|
1201
|
+
"-E",
|
|
1202
|
+
"-F",
|
|
1203
|
+
"-J",
|
|
1204
|
+
"-L",
|
|
1205
|
+
"-R",
|
|
1206
|
+
"-W",
|
|
1207
|
+
"-b",
|
|
1208
|
+
"-c",
|
|
1209
|
+
"-D",
|
|
1210
|
+
"-e",
|
|
1211
|
+
"-I",
|
|
1212
|
+
"-S"
|
|
1213
|
+
]),
|
|
1214
|
+
scp: /* @__PURE__ */ new Set(["-i", "-o", "-P", "-S"]),
|
|
1215
|
+
rsync: /* @__PURE__ */ new Set(["-e", "--rsh", "--rsync-path", "--password-file", "--log-file"]),
|
|
1216
|
+
socat: /* @__PURE__ */ new Set([])
|
|
1217
|
+
// socat uses address syntax, not flags — no value-flags
|
|
1218
|
+
};
|
|
1219
|
+
function extractPositionalArgs(tokens, binary) {
|
|
1220
|
+
const binaryName = basename(binary).replace(/\.exe$/i, "");
|
|
1221
|
+
const flagsWithValues = FLAGS_WITH_VALUES[binaryName] ?? /* @__PURE__ */ new Set();
|
|
1222
|
+
const positional = [];
|
|
1223
|
+
let skipNext = false;
|
|
1224
|
+
for (const token of tokens) {
|
|
1225
|
+
if (skipNext) {
|
|
1226
|
+
skipNext = false;
|
|
1227
|
+
continue;
|
|
1588
1228
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
trustLevel: "unknown",
|
|
1594
|
-
reason: "binary not found in PATH"
|
|
1595
|
-
};
|
|
1596
|
-
}
|
|
1597
|
-
try {
|
|
1598
|
-
const stat = fs5.statSync(resolved);
|
|
1599
|
-
if (stat.mode & 2) {
|
|
1600
|
-
return {
|
|
1601
|
-
resolvedPath: resolved,
|
|
1602
|
-
trustLevel: "suspect",
|
|
1603
|
-
reason: "binary is world-writable"
|
|
1604
|
-
};
|
|
1229
|
+
if (token.startsWith("--") && token.includes("=")) continue;
|
|
1230
|
+
if (token.startsWith("-") && token.length === 2 && flagsWithValues.has(token)) {
|
|
1231
|
+
skipNext = true;
|
|
1232
|
+
continue;
|
|
1605
1233
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1234
|
+
if (token.startsWith("--") && flagsWithValues.has(token)) {
|
|
1235
|
+
skipNext = true;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
const shortFlag = token.slice(0, 2);
|
|
1239
|
+
if (token.startsWith("-") && token.length > 2 && flagsWithValues.has(shortFlag)) continue;
|
|
1240
|
+
if (token.startsWith("-")) continue;
|
|
1241
|
+
if (token.startsWith("@")) continue;
|
|
1242
|
+
positional.push(token);
|
|
1612
1243
|
}
|
|
1613
|
-
|
|
1614
|
-
return { resolvedPath: resolved, ...classify };
|
|
1244
|
+
return positional;
|
|
1615
1245
|
}
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
"sed",
|
|
1625
|
-
"cut",
|
|
1626
|
-
"sort",
|
|
1627
|
-
"tee",
|
|
1628
|
-
"less",
|
|
1629
|
-
"more",
|
|
1630
|
-
"strings",
|
|
1631
|
-
"xxd"
|
|
1632
|
-
]);
|
|
1633
|
-
var SINK_COMMANDS = /* @__PURE__ */ new Set([
|
|
1634
|
-
"curl",
|
|
1635
|
-
"wget",
|
|
1636
|
-
"nc",
|
|
1637
|
-
"ncat",
|
|
1638
|
-
"netcat",
|
|
1639
|
-
"ssh",
|
|
1640
|
-
"scp",
|
|
1641
|
-
"rsync",
|
|
1642
|
-
"socat",
|
|
1643
|
-
"ftp",
|
|
1644
|
-
"sftp",
|
|
1645
|
-
"telnet"
|
|
1646
|
-
]);
|
|
1647
|
-
var OBFUSCATORS = /* @__PURE__ */ new Set([
|
|
1648
|
-
"base64",
|
|
1649
|
-
"gzip",
|
|
1650
|
-
"gunzip",
|
|
1651
|
-
"bzip2",
|
|
1652
|
-
"xz",
|
|
1653
|
-
"zstd",
|
|
1654
|
-
"openssl",
|
|
1655
|
-
"gpg",
|
|
1656
|
-
"python",
|
|
1657
|
-
"python3",
|
|
1658
|
-
"perl",
|
|
1659
|
-
"ruby",
|
|
1660
|
-
"node"
|
|
1661
|
-
]);
|
|
1662
|
-
var SENSITIVE_PATTERNS = [
|
|
1663
|
-
/(?:^|\/)\.env(?:\.|$)/i,
|
|
1664
|
-
// .env, .env.local, .env.production
|
|
1665
|
-
/id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
|
|
1666
|
-
// SSH private keys
|
|
1667
|
-
/\.pem$|\.key$|\.p12$|\.pfx$/i,
|
|
1668
|
-
// certificate files
|
|
1669
|
-
/(?:^|\/)\.ssh\//i,
|
|
1670
|
-
// ~/.ssh/ directory
|
|
1671
|
-
/(?:^|\/)\.aws\/credentials/i,
|
|
1672
|
-
// AWS credentials
|
|
1673
|
-
/(?:^|\/)\.netrc$/i,
|
|
1674
|
-
// netrc (stores HTTP credentials)
|
|
1675
|
-
/(?:^|\/)(passwd|shadow|sudoers)$/i,
|
|
1676
|
-
// /etc/passwd, /etc/shadow
|
|
1677
|
-
/(?:^|\/)credentials(?:\.json)?$/i
|
|
1678
|
-
// generic credentials files
|
|
1679
|
-
];
|
|
1680
|
-
function isSensitivePath(p) {
|
|
1681
|
-
return SENSITIVE_PATTERNS.some((re) => re.test(p));
|
|
1246
|
+
function extractNetworkTargets(tokens, binary) {
|
|
1247
|
+
return extractPositionalArgs(tokens, binary).map((t) => t.includes("@") ? t.split("@")[1] : t).map((t) => {
|
|
1248
|
+
const colonIdx = t.indexOf(":");
|
|
1249
|
+
if (colonIdx === -1) return t;
|
|
1250
|
+
const afterColon = t.slice(colonIdx + 1);
|
|
1251
|
+
if (/^\d+$/.test(afterColon)) return t.slice(0, colonIdx);
|
|
1252
|
+
return t;
|
|
1253
|
+
}).filter(Boolean);
|
|
1682
1254
|
}
|
|
1683
|
-
function
|
|
1684
|
-
const
|
|
1255
|
+
function tokenize(cmd) {
|
|
1256
|
+
const tokens = [];
|
|
1685
1257
|
let current = "";
|
|
1686
1258
|
let inSingle = false;
|
|
1687
1259
|
let inDouble = false;
|
|
1688
|
-
for (
|
|
1689
|
-
const ch = cmd[i];
|
|
1260
|
+
for (const ch of cmd) {
|
|
1690
1261
|
if (ch === "'" && !inDouble) {
|
|
1691
1262
|
inSingle = !inSingle;
|
|
1692
|
-
current += ch;
|
|
1693
1263
|
} else if (ch === '"' && !inSingle) {
|
|
1694
1264
|
inDouble = !inDouble;
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1265
|
+
} else if ((ch === " " || ch === " ") && !inSingle && !inDouble) {
|
|
1266
|
+
if (current) {
|
|
1267
|
+
tokens.push(current);
|
|
1268
|
+
current = "";
|
|
1269
|
+
}
|
|
1699
1270
|
} else {
|
|
1700
1271
|
current += ch;
|
|
1701
1272
|
}
|
|
1702
1273
|
}
|
|
1703
|
-
if (current
|
|
1704
|
-
return
|
|
1705
|
-
}
|
|
1706
|
-
function
|
|
1707
|
-
return
|
|
1708
|
-
}
|
|
1709
|
-
function
|
|
1710
|
-
const
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1274
|
+
if (current) tokens.push(current);
|
|
1275
|
+
return tokens;
|
|
1276
|
+
}
|
|
1277
|
+
function parseHost(raw) {
|
|
1278
|
+
return raw.split("@").pop().split(":")[0];
|
|
1279
|
+
}
|
|
1280
|
+
function extractAllSshHosts(tokens) {
|
|
1281
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
1282
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1283
|
+
const t = tokens[i];
|
|
1284
|
+
if (t === "-J" && tokens[i + 1]) {
|
|
1285
|
+
for (const hop of tokens[++i].split(",")) {
|
|
1286
|
+
const h = parseHost(hop);
|
|
1287
|
+
if (h) hosts.add(h);
|
|
1288
|
+
}
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
if (t === "-o" && tokens[i + 1]?.toLowerCase().startsWith("proxyjump=")) {
|
|
1292
|
+
const val = tokens[++i].split("=").slice(1).join("=");
|
|
1293
|
+
for (const hop of val.split(",")) {
|
|
1294
|
+
const h = parseHost(hop);
|
|
1295
|
+
if (h) hosts.add(h);
|
|
1296
|
+
}
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
if (t === "-o" && tokens[i + 1]?.toLowerCase().startsWith("proxycommand=")) {
|
|
1300
|
+
const raw = tokens[++i].split("=").slice(1).join("=").replace(/^['"]|['"]$/g, "");
|
|
1301
|
+
const subTokens = tokenize(raw);
|
|
1302
|
+
const binary = subTokens[0] ?? "";
|
|
1303
|
+
extractNetworkTargets(subTokens.slice(1), binary).forEach((h) => hosts.add(h));
|
|
1304
|
+
extractAllSshHosts(subTokens.slice(1)).forEach((h) => hosts.add(h));
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
if (!t.startsWith("-")) {
|
|
1308
|
+
const h = parseHost(t);
|
|
1309
|
+
if (h) hosts.add(h);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return [...hosts].filter(Boolean);
|
|
1313
|
+
}
|
|
1314
|
+
var MAX_REGEX_LENGTH = 100;
|
|
1315
|
+
var REGEX_CACHE_MAX = 500;
|
|
1316
|
+
var regexCache = /* @__PURE__ */ new Map();
|
|
1317
|
+
function validateRegex(pattern) {
|
|
1318
|
+
if (!pattern) return "Pattern is required";
|
|
1319
|
+
if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
|
|
1320
|
+
try {
|
|
1321
|
+
new RegExp(pattern);
|
|
1322
|
+
} catch (e) {
|
|
1323
|
+
return `Invalid regex syntax: ${e.message}`;
|
|
1324
|
+
}
|
|
1325
|
+
if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
|
|
1326
|
+
if (!safeRegex2(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
function getCompiledRegex(pattern, flags = "") {
|
|
1330
|
+
if (flags && !/^[gimsuy]+$/.test(flags)) return null;
|
|
1331
|
+
const key = `${pattern}\0${flags}`;
|
|
1332
|
+
if (regexCache.has(key)) {
|
|
1333
|
+
const cached = regexCache.get(key);
|
|
1334
|
+
regexCache.delete(key);
|
|
1335
|
+
regexCache.set(key, cached);
|
|
1336
|
+
return cached;
|
|
1337
|
+
}
|
|
1338
|
+
if (validateRegex(pattern) !== null) return null;
|
|
1339
|
+
try {
|
|
1340
|
+
const re = new RegExp(pattern, flags);
|
|
1341
|
+
if (regexCache.size >= REGEX_CACHE_MAX) {
|
|
1342
|
+
const oldest = regexCache.keys().next().value;
|
|
1343
|
+
if (oldest) regexCache.delete(oldest);
|
|
1344
|
+
}
|
|
1345
|
+
regexCache.set(key, re);
|
|
1346
|
+
return re;
|
|
1347
|
+
} catch {
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
function matchesPattern(text, patterns) {
|
|
1352
|
+
const p = Array.isArray(patterns) ? patterns : [patterns];
|
|
1353
|
+
if (p.length === 0) return false;
|
|
1354
|
+
const isMatch = pm(p, { nocase: true, dot: true });
|
|
1355
|
+
const target = text.toLowerCase();
|
|
1356
|
+
const directMatch = isMatch(target);
|
|
1357
|
+
if (directMatch) return true;
|
|
1358
|
+
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
1359
|
+
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
1360
|
+
}
|
|
1361
|
+
var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1362
|
+
function getNestedValue(obj, path13) {
|
|
1363
|
+
if (!obj || typeof obj !== "object") return null;
|
|
1364
|
+
const segments = path13.split(".");
|
|
1365
|
+
for (const seg of segments) {
|
|
1366
|
+
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
|
|
1367
|
+
}
|
|
1368
|
+
return segments.reduce((prev, curr) => prev?.[curr], obj);
|
|
1369
|
+
}
|
|
1370
|
+
function evaluateSmartConditions(args, rule) {
|
|
1371
|
+
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
1372
|
+
const mode = rule.conditionMode ?? "all";
|
|
1373
|
+
const results = rule.conditions.map((cond) => {
|
|
1374
|
+
const rawVal = getNestedValue(args, cond.field);
|
|
1375
|
+
const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
|
|
1376
|
+
const val = cond.field === "command" && normalized !== null ? normalizeCommandForPolicy(normalized) : normalized;
|
|
1377
|
+
switch (cond.op) {
|
|
1378
|
+
case "exists":
|
|
1379
|
+
return val !== null && val !== "";
|
|
1380
|
+
case "notExists":
|
|
1381
|
+
return val === null || val === "";
|
|
1382
|
+
case "contains":
|
|
1383
|
+
return val !== null && cond.value ? val.includes(cond.value) : false;
|
|
1384
|
+
case "notContains":
|
|
1385
|
+
return val !== null && cond.value ? !val.includes(cond.value) : true;
|
|
1386
|
+
case "matches": {
|
|
1387
|
+
if (val === null || !cond.value) return false;
|
|
1388
|
+
const reM = getCompiledRegex(cond.value, cond.flags ?? "");
|
|
1389
|
+
if (!reM) return false;
|
|
1390
|
+
return reM.test(val);
|
|
1391
|
+
}
|
|
1392
|
+
case "notMatches": {
|
|
1393
|
+
if (!cond.value) return false;
|
|
1394
|
+
if (val === null) return true;
|
|
1395
|
+
const reN = getCompiledRegex(cond.value, cond.flags ?? "");
|
|
1396
|
+
if (!reN) return false;
|
|
1397
|
+
return !reN.test(val);
|
|
1398
|
+
}
|
|
1399
|
+
case "matchesGlob":
|
|
1400
|
+
return val !== null && cond.value ? pm.isMatch(val, cond.value) : false;
|
|
1401
|
+
case "notMatchesGlob":
|
|
1402
|
+
return val !== null && cond.value ? !pm.isMatch(val, cond.value) : false;
|
|
1403
|
+
default:
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
return mode === "any" ? results.some((r) => r) : results.every((r) => r);
|
|
1408
|
+
}
|
|
1409
|
+
function tokenize2(toolName) {
|
|
1410
|
+
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
1411
|
+
}
|
|
1412
|
+
function extractShellCommand(toolName, args, toolInspection) {
|
|
1413
|
+
const patterns = Object.keys(toolInspection);
|
|
1414
|
+
const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
|
|
1415
|
+
if (!matchingPattern) return null;
|
|
1416
|
+
const fieldPath = toolInspection[matchingPattern];
|
|
1417
|
+
const value = getNestedValue(args, fieldPath);
|
|
1418
|
+
return typeof value === "string" ? value : null;
|
|
1419
|
+
}
|
|
1420
|
+
function isSqlTool(toolName, toolInspection) {
|
|
1421
|
+
const patterns = Object.keys(toolInspection);
|
|
1422
|
+
const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
|
|
1423
|
+
if (!matchingPattern) return false;
|
|
1424
|
+
const fieldName = toolInspection[matchingPattern];
|
|
1425
|
+
return fieldName === "sql" || fieldName === "query";
|
|
1426
|
+
}
|
|
1427
|
+
var SQL_DML_KEYWORDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "merge", "upsert"]);
|
|
1428
|
+
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
1429
|
+
const { agent, cwd, activeEnvironment } = context;
|
|
1430
|
+
const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
|
|
1431
|
+
const wouldBeIgnored = matchesPattern(toolName, config.policy.ignoredTools);
|
|
1432
|
+
if (config.policy.dlp.enabled && (!wouldBeIgnored || config.policy.dlp.scanIgnoredTools)) {
|
|
1433
|
+
const dlpMatch = args !== void 0 ? scanArgs(args) : null;
|
|
1434
|
+
if (dlpMatch) {
|
|
1435
|
+
return {
|
|
1436
|
+
decision: dlpMatch.severity,
|
|
1437
|
+
blockedByLabel: `DLP: ${dlpMatch.patternName}`,
|
|
1438
|
+
reason: `${dlpMatch.patternName} detected in ${dlpMatch.fieldPath}`
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
if (wouldBeIgnored) return { decision: "allow" };
|
|
1443
|
+
if (config.policy.smartRules.length > 0) {
|
|
1444
|
+
const matchedRule = config.policy.smartRules.find(
|
|
1445
|
+
(rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
|
|
1446
|
+
);
|
|
1447
|
+
if (matchedRule) {
|
|
1448
|
+
if (matchedRule.verdict === "allow")
|
|
1449
|
+
return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
|
|
1450
|
+
return {
|
|
1451
|
+
decision: matchedRule.verdict,
|
|
1452
|
+
blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
|
|
1453
|
+
reason: matchedRule.reason,
|
|
1454
|
+
tier: 2,
|
|
1455
|
+
ruleName: matchedRule.name ?? matchedRule.tool,
|
|
1456
|
+
...(matchedRule.description ?? matchedRule.reason) && {
|
|
1457
|
+
ruleDescription: matchedRule.description ?? matchedRule.reason
|
|
1458
|
+
},
|
|
1459
|
+
...matchedRule.verdict === "block" && matchedRule.dependsOnState?.length && {
|
|
1460
|
+
dependsOnStatePredicates: matchedRule.dependsOnState
|
|
1461
|
+
},
|
|
1462
|
+
...matchedRule.verdict === "block" && matchedRule.recoveryCommand && {
|
|
1463
|
+
recoveryCommand: matchedRule.recoveryCommand
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1721
1467
|
}
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1468
|
+
let allTokens = [];
|
|
1469
|
+
let pathTokens = [];
|
|
1470
|
+
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
1471
|
+
if (shellCommand) {
|
|
1472
|
+
const analyzed = analyzeShellCommand(shellCommand);
|
|
1473
|
+
allTokens = analyzed.allTokens;
|
|
1474
|
+
pathTokens = analyzed.paths;
|
|
1475
|
+
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
|
|
1476
|
+
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
1477
|
+
return {
|
|
1478
|
+
decision: "review",
|
|
1479
|
+
blockedByLabel: "Node9 Standard (Inline Execution)",
|
|
1480
|
+
ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
|
|
1481
|
+
tier: 3
|
|
1482
|
+
};
|
|
1735
1483
|
}
|
|
1736
|
-
|
|
1737
|
-
if (
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1484
|
+
const evalVerdict = detectDangerousShellExec(shellCommand);
|
|
1485
|
+
if (evalVerdict === "block") {
|
|
1486
|
+
return {
|
|
1487
|
+
decision: "block",
|
|
1488
|
+
blockedByLabel: "Node9: Eval Remote Execution",
|
|
1489
|
+
reason: "eval of remote download (curl/wget) is a near-certain supply-chain attack",
|
|
1490
|
+
ruleDescription: "The AI is downloading a script from the internet and running it immediately without inspection. This is a common way malware gets installed.",
|
|
1491
|
+
tier: 3
|
|
1492
|
+
};
|
|
1743
1493
|
}
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1494
|
+
if (evalVerdict === "review") {
|
|
1495
|
+
return {
|
|
1496
|
+
decision: "review",
|
|
1497
|
+
blockedByLabel: "Node9: Eval Dynamic Content",
|
|
1498
|
+
reason: "eval of dynamic content (variable or subshell expansion) requires approval",
|
|
1499
|
+
ruleDescription: "The AI is running a command that includes a variable or subshell expansion. The actual command executed at runtime may differ from what is shown here.",
|
|
1500
|
+
tier: 3
|
|
1501
|
+
};
|
|
1751
1502
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
"
|
|
1788
|
-
"
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
"
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
"-o",
|
|
1815
|
-
"-E",
|
|
1816
|
-
"-F",
|
|
1817
|
-
"-J",
|
|
1818
|
-
"-L",
|
|
1819
|
-
"-R",
|
|
1820
|
-
"-W",
|
|
1821
|
-
"-b",
|
|
1822
|
-
"-c",
|
|
1823
|
-
"-D",
|
|
1824
|
-
"-e",
|
|
1825
|
-
"-I",
|
|
1826
|
-
"-S"
|
|
1827
|
-
]),
|
|
1828
|
-
scp: /* @__PURE__ */ new Set(["-i", "-o", "-P", "-S"]),
|
|
1829
|
-
rsync: /* @__PURE__ */ new Set(["-e", "--rsh", "--rsync-path", "--password-file", "--log-file"]),
|
|
1830
|
-
socat: /* @__PURE__ */ new Set([])
|
|
1831
|
-
// socat uses address syntax, not flags — no value-flags
|
|
1832
|
-
};
|
|
1833
|
-
function extractPositionalArgs(tokens, binary) {
|
|
1834
|
-
const binaryName = path6.basename(binary).replace(/\.exe$/i, "");
|
|
1835
|
-
const flagsWithValues = FLAGS_WITH_VALUES[binaryName] ?? /* @__PURE__ */ new Set();
|
|
1836
|
-
const positional = [];
|
|
1837
|
-
let skipNext = false;
|
|
1838
|
-
for (const token of tokens) {
|
|
1839
|
-
if (skipNext) {
|
|
1840
|
-
skipNext = false;
|
|
1841
|
-
continue;
|
|
1503
|
+
const pipeAnalysis = analyzePipeChain(shellCommand);
|
|
1504
|
+
if (pipeAnalysis.isPipeline && (pipeAnalysis.risk === "critical" || pipeAnalysis.risk === "high")) {
|
|
1505
|
+
const sinks = pipeAnalysis.sinkTargets;
|
|
1506
|
+
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost2 ? isTrustedHost2(host) : false);
|
|
1507
|
+
if (pipeAnalysis.risk === "critical") {
|
|
1508
|
+
if (allTrusted) {
|
|
1509
|
+
return {
|
|
1510
|
+
decision: "review",
|
|
1511
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1512
|
+
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1513
|
+
tier: 3
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
return {
|
|
1517
|
+
decision: "block",
|
|
1518
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1519
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1520
|
+
tier: 3
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
if (allTrusted) {
|
|
1524
|
+
return {
|
|
1525
|
+
decision: "allow",
|
|
1526
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1527
|
+
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1528
|
+
tier: 3
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
return {
|
|
1532
|
+
decision: "review",
|
|
1533
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1534
|
+
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1535
|
+
tier: 3
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
const firstToken = analyzed.actions[0] ?? "";
|
|
1539
|
+
if (["ssh", "scp", "rsync"].includes(firstToken)) {
|
|
1540
|
+
const rawTokens = shellCommand.trim().split(/\s+/);
|
|
1541
|
+
const sshHosts = extractAllSshHosts(rawTokens.slice(1));
|
|
1542
|
+
allTokens.push(...sshHosts);
|
|
1543
|
+
}
|
|
1544
|
+
if (firstToken && firstToken.startsWith("/") && checkProvenance2) {
|
|
1545
|
+
const prov = checkProvenance2(firstToken, cwd);
|
|
1546
|
+
if (prov.trustLevel === "suspect") {
|
|
1547
|
+
return {
|
|
1548
|
+
decision: config.settings.mode === "strict" ? "block" : "review",
|
|
1549
|
+
blockedByLabel: "Node9: Suspect Binary",
|
|
1550
|
+
reason: `Binary "${firstToken}" resolved to ${prov.resolvedPath} \u2014 ${prov.reason}`,
|
|
1551
|
+
tier: 3
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
if (prov.trustLevel === "unknown" && config.settings.mode === "strict") {
|
|
1555
|
+
return {
|
|
1556
|
+
decision: "review",
|
|
1557
|
+
blockedByLabel: "Node9: Unknown Binary (strict mode)",
|
|
1558
|
+
reason: `Binary "${firstToken}" \u2014 ${prov.reason}`,
|
|
1559
|
+
tier: 3
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
if (isSqlTool(toolName, config.policy.toolInspection)) {
|
|
1564
|
+
allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
|
|
1842
1565
|
}
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1566
|
+
} else {
|
|
1567
|
+
allTokens = tokenize2(toolName);
|
|
1568
|
+
if (args && typeof args === "object") {
|
|
1569
|
+
const flattenedArgs = JSON.stringify(args).toLowerCase();
|
|
1570
|
+
const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
|
|
1571
|
+
allTokens.push(...extraTokens);
|
|
1847
1572
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1573
|
+
}
|
|
1574
|
+
const isManual = agent === "Terminal";
|
|
1575
|
+
if (isManual) {
|
|
1576
|
+
const SYSTEM_DISASTER_COMMANDS = ["mkfs", "shred", "dd", "drop", "truncate", "purge"];
|
|
1577
|
+
const hasSystemDisaster = allTokens.some(
|
|
1578
|
+
(t) => SYSTEM_DISASTER_COMMANDS.includes(t.toLowerCase())
|
|
1579
|
+
);
|
|
1580
|
+
const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
|
|
1581
|
+
if (hasSystemDisaster || isRootWipe) {
|
|
1582
|
+
return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
|
|
1851
1583
|
}
|
|
1852
|
-
|
|
1853
|
-
if (token.startsWith("-") && token.length > 2 && flagsWithValues.has(shortFlag)) continue;
|
|
1854
|
-
if (token.startsWith("-")) continue;
|
|
1855
|
-
if (token.startsWith("@")) continue;
|
|
1856
|
-
positional.push(token);
|
|
1584
|
+
return { decision: "allow" };
|
|
1857
1585
|
}
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1586
|
+
if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
|
|
1587
|
+
const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
|
|
1588
|
+
if (allInSandbox) return { decision: "allow" };
|
|
1589
|
+
}
|
|
1590
|
+
let matchedDangerousWord;
|
|
1591
|
+
const isDangerous = allTokens.some(
|
|
1592
|
+
(token) => config.policy.dangerousWords.some((word) => {
|
|
1593
|
+
const w = word.toLowerCase();
|
|
1594
|
+
const hit = token === w || (() => {
|
|
1595
|
+
try {
|
|
1596
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
1597
|
+
} catch {
|
|
1598
|
+
return false;
|
|
1599
|
+
}
|
|
1600
|
+
})();
|
|
1601
|
+
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
1602
|
+
return hit;
|
|
1603
|
+
})
|
|
1604
|
+
);
|
|
1605
|
+
if (isDangerous) {
|
|
1606
|
+
let matchedField;
|
|
1607
|
+
if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
|
|
1608
|
+
const obj = args;
|
|
1609
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1610
|
+
if (typeof value === "string") {
|
|
1611
|
+
try {
|
|
1612
|
+
if (new RegExp(
|
|
1613
|
+
`\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
1614
|
+
"i"
|
|
1615
|
+
).test(value)) {
|
|
1616
|
+
matchedField = key;
|
|
1617
|
+
break;
|
|
1618
|
+
}
|
|
1619
|
+
} catch {
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1885
1622
|
}
|
|
1886
|
-
} else {
|
|
1887
|
-
current += ch;
|
|
1888
1623
|
}
|
|
1624
|
+
return {
|
|
1625
|
+
decision: "review",
|
|
1626
|
+
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
|
|
1627
|
+
matchedWord: matchedDangerousWord,
|
|
1628
|
+
matchedField,
|
|
1629
|
+
ruleDescription: `This command contains a flagged keyword ("${matchedDangerousWord}") from your node9 config. Review it before allowing.`,
|
|
1630
|
+
tier: 6
|
|
1631
|
+
};
|
|
1889
1632
|
}
|
|
1890
|
-
if (
|
|
1891
|
-
|
|
1633
|
+
if (config.settings.mode === "strict") {
|
|
1634
|
+
if (activeEnvironment?.requireApproval === false) return { decision: "allow" };
|
|
1635
|
+
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
|
|
1636
|
+
}
|
|
1637
|
+
return { decision: "allow" };
|
|
1892
1638
|
}
|
|
1893
|
-
function
|
|
1894
|
-
return
|
|
1639
|
+
function isIgnoredTool(toolName, config) {
|
|
1640
|
+
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
1895
1641
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1642
|
+
var aws_default = {
|
|
1643
|
+
name: "aws",
|
|
1644
|
+
description: "Protects AWS infrastructure from destructive AI operations",
|
|
1645
|
+
aliases: ["amazon"],
|
|
1646
|
+
smartRules: [
|
|
1647
|
+
{
|
|
1648
|
+
name: "shield:aws:block-delete-s3-bucket",
|
|
1649
|
+
tool: "*",
|
|
1650
|
+
conditions: [
|
|
1651
|
+
{
|
|
1652
|
+
field: "command",
|
|
1653
|
+
op: "matches",
|
|
1654
|
+
value: "aws\\s+s3.*rb\\s|aws\\s+s3api\\s+delete-bucket",
|
|
1655
|
+
flags: "i"
|
|
1656
|
+
}
|
|
1657
|
+
],
|
|
1658
|
+
verdict: "block",
|
|
1659
|
+
reason: "S3 bucket deletion is irreversible \u2014 blocked by AWS shield"
|
|
1660
|
+
},
|
|
1661
|
+
{
|
|
1662
|
+
name: "shield:aws:review-iam-changes",
|
|
1663
|
+
tool: "*",
|
|
1664
|
+
conditions: [
|
|
1665
|
+
{
|
|
1666
|
+
field: "command",
|
|
1667
|
+
op: "matches",
|
|
1668
|
+
value: "aws\\s+iam\\s+(create|delete|attach|detach|put|remove)",
|
|
1669
|
+
flags: "i"
|
|
1670
|
+
}
|
|
1671
|
+
],
|
|
1672
|
+
verdict: "review",
|
|
1673
|
+
reason: "IAM changes require human approval (AWS shield)"
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
name: "shield:aws:block-ec2-terminate",
|
|
1677
|
+
tool: "*",
|
|
1678
|
+
conditions: [
|
|
1679
|
+
{
|
|
1680
|
+
field: "command",
|
|
1681
|
+
op: "matches",
|
|
1682
|
+
value: "aws\\s+ec2\\s+terminate-instances",
|
|
1683
|
+
flags: "i"
|
|
1684
|
+
}
|
|
1685
|
+
],
|
|
1686
|
+
verdict: "block",
|
|
1687
|
+
reason: "EC2 instance termination is irreversible \u2014 blocked by AWS shield"
|
|
1688
|
+
},
|
|
1689
|
+
{
|
|
1690
|
+
name: "shield:aws:review-rds-delete",
|
|
1691
|
+
tool: "*",
|
|
1692
|
+
conditions: [
|
|
1693
|
+
{ field: "command", op: "matches", value: "aws\\s+rds\\s+delete-", flags: "i" }
|
|
1694
|
+
],
|
|
1695
|
+
verdict: "review",
|
|
1696
|
+
reason: "RDS deletion requires human approval (AWS shield)"
|
|
1906
1697
|
}
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1698
|
+
],
|
|
1699
|
+
dangerousWords: []
|
|
1700
|
+
};
|
|
1701
|
+
var bash_safe_default = {
|
|
1702
|
+
name: "bash-safe",
|
|
1703
|
+
description: "Blocks high-risk bash patterns: pipe-to-shell, rm -rf /, disk overwrites, eval",
|
|
1704
|
+
aliases: ["bash", "shell"],
|
|
1705
|
+
smartRules: [
|
|
1706
|
+
{
|
|
1707
|
+
name: "shield:bash-safe:block-pipe-to-shell",
|
|
1708
|
+
tool: "bash",
|
|
1709
|
+
conditions: [
|
|
1710
|
+
{
|
|
1711
|
+
field: "command",
|
|
1712
|
+
op: "matches",
|
|
1713
|
+
value: "(^|&&|\\|\\||;)\\s*(curl|wget)\\s+[^|]*\\|\\s*(?:(bash|sh|zsh|fish)|(python3?|ruby|perl|node)\\b(?!\\s+-[cem]\\b))",
|
|
1714
|
+
flags: "i"
|
|
1715
|
+
}
|
|
1716
|
+
],
|
|
1717
|
+
verdict: "block",
|
|
1718
|
+
reason: "Pipe-to-shell is a common supply-chain attack vector \u2014 blocked by bash-safe shield"
|
|
1719
|
+
},
|
|
1720
|
+
{
|
|
1721
|
+
name: "shield:bash-safe:block-obfuscated-exec",
|
|
1722
|
+
tool: "bash",
|
|
1723
|
+
conditions: [
|
|
1724
|
+
{
|
|
1725
|
+
field: "command",
|
|
1726
|
+
op: "matches",
|
|
1727
|
+
value: "\\bbase64\\s+(-d|--decode)[^|;&]*\\|\\s*(bash|sh|zsh)",
|
|
1728
|
+
flags: "i"
|
|
1729
|
+
}
|
|
1730
|
+
],
|
|
1731
|
+
verdict: "block",
|
|
1732
|
+
reason: "Obfuscated execution via base64 decode \u2014 blocked by bash-safe shield"
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
name: "shield:bash-safe:block-rm-root",
|
|
1736
|
+
tool: "bash",
|
|
1737
|
+
conditions: [
|
|
1738
|
+
{
|
|
1739
|
+
field: "command",
|
|
1740
|
+
op: "matches",
|
|
1741
|
+
value: "rm\\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)[a-zA-Z]*\\s+(\\/|~|\\$HOME|\\$\\{HOME\\})\\s*$",
|
|
1742
|
+
flags: "i"
|
|
1743
|
+
}
|
|
1744
|
+
],
|
|
1745
|
+
verdict: "block",
|
|
1746
|
+
reason: "rm -rf of root or home directory is catastrophic \u2014 blocked by bash-safe shield"
|
|
1747
|
+
},
|
|
1748
|
+
{
|
|
1749
|
+
name: "shield:bash-safe:block-disk-overwrite",
|
|
1750
|
+
tool: "bash",
|
|
1751
|
+
conditions: [
|
|
1752
|
+
{
|
|
1753
|
+
field: "command",
|
|
1754
|
+
op: "matches",
|
|
1755
|
+
value: "(^|&&|\\|\\||;)\\s*dd\\s+.*of=\\/dev\\/(sd|nvme|hd|vd|xvd)",
|
|
1756
|
+
flags: "i"
|
|
1757
|
+
}
|
|
1758
|
+
],
|
|
1759
|
+
verdict: "block",
|
|
1760
|
+
reason: "Writing directly to a block device is irreversible \u2014 blocked by bash-safe shield"
|
|
1761
|
+
},
|
|
1762
|
+
{
|
|
1763
|
+
name: "shield:bash-safe:block-eval-remote",
|
|
1764
|
+
tool: "bash",
|
|
1765
|
+
conditions: [
|
|
1766
|
+
{
|
|
1767
|
+
field: "command",
|
|
1768
|
+
op: "matches",
|
|
1769
|
+
value: "(^|&&|\\|\\||;)\\s*eval\\s+.*\\$\\((curl|wget)\\b",
|
|
1770
|
+
flags: "i"
|
|
1771
|
+
}
|
|
1772
|
+
],
|
|
1773
|
+
verdict: "block",
|
|
1774
|
+
reason: "eval of remote download is a near-certain supply-chain attack \u2014 blocked by bash-safe shield"
|
|
1775
|
+
},
|
|
1776
|
+
{
|
|
1777
|
+
name: "shield:bash-safe:review-eval-dynamic",
|
|
1778
|
+
tool: "bash",
|
|
1779
|
+
conditions: [
|
|
1780
|
+
{
|
|
1781
|
+
field: "command",
|
|
1782
|
+
op: "matches",
|
|
1783
|
+
value: '(^|&&|\\|\\||[;|\\n{(`])\\s*eval\\s+([\\$`(]|"[^"]*\\$)',
|
|
1784
|
+
flags: "i"
|
|
1785
|
+
}
|
|
1786
|
+
],
|
|
1787
|
+
verdict: "review",
|
|
1788
|
+
reason: "eval of dynamic content \u2014 backup regex rule for scan path (real-time uses AST detection)"
|
|
1914
1789
|
}
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1790
|
+
],
|
|
1791
|
+
dangerousWords: []
|
|
1792
|
+
};
|
|
1793
|
+
var docker_default = {
|
|
1794
|
+
name: "docker",
|
|
1795
|
+
description: "Protects Docker environments from destructive AI operations",
|
|
1796
|
+
aliases: [],
|
|
1797
|
+
smartRules: [
|
|
1798
|
+
{
|
|
1799
|
+
name: "shield:docker:block-system-prune",
|
|
1800
|
+
tool: "*",
|
|
1801
|
+
conditions: [
|
|
1802
|
+
{
|
|
1803
|
+
field: "command",
|
|
1804
|
+
op: "matches",
|
|
1805
|
+
value: "docker\\s+system\\s+prune",
|
|
1806
|
+
flags: "i"
|
|
1807
|
+
}
|
|
1808
|
+
],
|
|
1809
|
+
verdict: "block",
|
|
1810
|
+
reason: "docker system prune removes all unused containers, images, and volumes \u2014 blocked by Docker shield"
|
|
1811
|
+
},
|
|
1812
|
+
{
|
|
1813
|
+
name: "shield:docker:block-volume-prune",
|
|
1814
|
+
tool: "*",
|
|
1815
|
+
conditions: [
|
|
1816
|
+
{
|
|
1817
|
+
field: "command",
|
|
1818
|
+
op: "matches",
|
|
1819
|
+
value: "docker\\s+volume\\s+prune",
|
|
1820
|
+
flags: "i"
|
|
1821
|
+
}
|
|
1822
|
+
],
|
|
1823
|
+
verdict: "block",
|
|
1824
|
+
reason: "docker volume prune destroys all unused volumes and their data \u2014 blocked by Docker shield"
|
|
1825
|
+
},
|
|
1826
|
+
{
|
|
1827
|
+
name: "shield:docker:block-rm-force",
|
|
1828
|
+
tool: "*",
|
|
1829
|
+
conditionMode: "all",
|
|
1830
|
+
conditions: [
|
|
1831
|
+
{
|
|
1832
|
+
field: "command",
|
|
1833
|
+
op: "matches",
|
|
1834
|
+
value: "docker\\s+rm\\b",
|
|
1835
|
+
flags: "i"
|
|
1836
|
+
},
|
|
1837
|
+
{
|
|
1838
|
+
field: "command",
|
|
1839
|
+
op: "matches",
|
|
1840
|
+
value: "(^|\\s)(-f|--force)(\\s|$)",
|
|
1841
|
+
flags: "i"
|
|
1842
|
+
}
|
|
1843
|
+
],
|
|
1844
|
+
verdict: "block",
|
|
1845
|
+
reason: "Force-removing running containers is destructive \u2014 blocked by Docker shield"
|
|
1846
|
+
},
|
|
1847
|
+
{
|
|
1848
|
+
name: "shield:docker:review-volume-rm",
|
|
1849
|
+
tool: "*",
|
|
1850
|
+
conditions: [
|
|
1851
|
+
{
|
|
1852
|
+
field: "command",
|
|
1853
|
+
op: "matches",
|
|
1854
|
+
value: "docker\\s+volume\\s+rm\\s+",
|
|
1855
|
+
flags: "i"
|
|
1856
|
+
}
|
|
1857
|
+
],
|
|
1858
|
+
verdict: "review",
|
|
1859
|
+
reason: "Volume removal deletes persistent data and requires human approval (Docker shield)"
|
|
1860
|
+
},
|
|
1861
|
+
{
|
|
1862
|
+
name: "shield:docker:review-stop-kill",
|
|
1863
|
+
tool: "*",
|
|
1864
|
+
conditions: [
|
|
1865
|
+
{
|
|
1866
|
+
field: "command",
|
|
1867
|
+
op: "matches",
|
|
1868
|
+
value: "docker\\s+(stop|kill)\\s+",
|
|
1869
|
+
flags: "i"
|
|
1870
|
+
}
|
|
1871
|
+
],
|
|
1872
|
+
verdict: "review",
|
|
1873
|
+
reason: "Stopping or killing containers requires human approval (Docker shield)"
|
|
1874
|
+
},
|
|
1875
|
+
{
|
|
1876
|
+
name: "shield:docker:review-image-rm",
|
|
1877
|
+
tool: "*",
|
|
1878
|
+
conditions: [
|
|
1879
|
+
{
|
|
1880
|
+
field: "command",
|
|
1881
|
+
op: "matches",
|
|
1882
|
+
value: "docker\\s+image\\s+rm\\b",
|
|
1883
|
+
flags: "i"
|
|
1884
|
+
}
|
|
1885
|
+
],
|
|
1886
|
+
verdict: "review",
|
|
1887
|
+
reason: "Image removal requires human approval (Docker shield)"
|
|
1888
|
+
},
|
|
1889
|
+
{
|
|
1890
|
+
name: "shield:docker:review-rmi-force",
|
|
1891
|
+
tool: "*",
|
|
1892
|
+
conditionMode: "all",
|
|
1893
|
+
conditions: [
|
|
1894
|
+
{
|
|
1895
|
+
field: "command",
|
|
1896
|
+
op: "matches",
|
|
1897
|
+
value: "docker\\s+rmi\\b",
|
|
1898
|
+
flags: "i"
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
field: "command",
|
|
1902
|
+
op: "matches",
|
|
1903
|
+
value: "(^|\\s)(-f|--force)(\\s|$)",
|
|
1904
|
+
flags: "i"
|
|
1905
|
+
}
|
|
1906
|
+
],
|
|
1907
|
+
verdict: "review",
|
|
1908
|
+
reason: "Force image removal requires human approval (Docker shield)"
|
|
1909
|
+
}
|
|
1910
|
+
],
|
|
1911
|
+
dangerousWords: []
|
|
1912
|
+
};
|
|
1913
|
+
var filesystem_default = {
|
|
1914
|
+
name: "filesystem",
|
|
1915
|
+
description: "Protects the local filesystem from dangerous AI operations",
|
|
1916
|
+
aliases: ["fs"],
|
|
1917
|
+
smartRules: [
|
|
1918
|
+
{
|
|
1919
|
+
name: "shield:filesystem:review-chmod-777",
|
|
1920
|
+
tool: "bash",
|
|
1921
|
+
conditions: [
|
|
1922
|
+
{ field: "command", op: "matches", value: "chmod\\s+(777|a\\+rwx)", flags: "i" }
|
|
1923
|
+
],
|
|
1924
|
+
verdict: "review",
|
|
1925
|
+
reason: "chmod 777 requires human approval (filesystem shield)"
|
|
1926
|
+
},
|
|
1927
|
+
{
|
|
1928
|
+
name: "shield:filesystem:review-write-etc",
|
|
1929
|
+
tool: "bash",
|
|
1930
|
+
conditions: [
|
|
1931
|
+
{
|
|
1932
|
+
field: "command",
|
|
1933
|
+
op: "matches",
|
|
1934
|
+
value: "(tee|\\bcp\\b|\\bmv\\b|install|>+)\\s+.*\\/etc\\/"
|
|
1935
|
+
}
|
|
1936
|
+
],
|
|
1937
|
+
verdict: "review",
|
|
1938
|
+
reason: "Writing to /etc requires human approval (filesystem shield)"
|
|
1939
|
+
}
|
|
1940
|
+
],
|
|
1941
|
+
dangerousWords: ["wipefs"]
|
|
1942
|
+
};
|
|
1943
|
+
var github_default = {
|
|
1944
|
+
name: "github",
|
|
1945
|
+
description: "Protects GitHub repositories from destructive AI operations",
|
|
1946
|
+
aliases: ["git"],
|
|
1947
|
+
smartRules: [
|
|
1948
|
+
{
|
|
1949
|
+
name: "shield:github:review-delete-branch-remote",
|
|
1950
|
+
tool: "bash",
|
|
1951
|
+
conditions: [
|
|
1952
|
+
{ field: "command", op: "matches", value: "git\\s+push\\s+.*--delete", flags: "i" }
|
|
1953
|
+
],
|
|
1954
|
+
verdict: "review",
|
|
1955
|
+
reason: "Remote branch deletion requires human approval (GitHub shield)"
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
name: "shield:github:block-delete-repo",
|
|
1959
|
+
tool: "*",
|
|
1960
|
+
conditions: [
|
|
1961
|
+
{ field: "command", op: "matches", value: "gh\\s+repo\\s+delete", flags: "i" }
|
|
1962
|
+
],
|
|
1963
|
+
verdict: "block",
|
|
1964
|
+
reason: "Repository deletion is irreversible \u2014 blocked by GitHub shield"
|
|
1965
|
+
}
|
|
1966
|
+
],
|
|
1967
|
+
dangerousWords: []
|
|
1968
|
+
};
|
|
1969
|
+
var k8s_default = {
|
|
1970
|
+
name: "k8s",
|
|
1971
|
+
description: "Protects Kubernetes clusters from destructive AI operations",
|
|
1972
|
+
aliases: ["kubernetes", "kubectl"],
|
|
1973
|
+
smartRules: [
|
|
1974
|
+
{
|
|
1975
|
+
name: "shield:k8s:block-delete-namespace",
|
|
1976
|
+
tool: "*",
|
|
1977
|
+
conditions: [
|
|
1978
|
+
{
|
|
1979
|
+
field: "command",
|
|
1980
|
+
op: "matches",
|
|
1981
|
+
value: "kubectl\\s+delete\\s+(ns|namespace)\\s+",
|
|
1982
|
+
flags: "i"
|
|
1983
|
+
}
|
|
1984
|
+
],
|
|
1985
|
+
verdict: "block",
|
|
1986
|
+
reason: "Deleting a namespace destroys all resources inside it \u2014 blocked by k8s shield"
|
|
1987
|
+
},
|
|
1988
|
+
{
|
|
1989
|
+
name: "shield:k8s:block-delete-all",
|
|
1990
|
+
tool: "*",
|
|
1991
|
+
conditions: [
|
|
1992
|
+
{
|
|
1993
|
+
field: "command",
|
|
1994
|
+
op: "matches",
|
|
1995
|
+
value: "kubectl\\s+delete\\s+.*--all\\b",
|
|
1996
|
+
flags: "i"
|
|
1997
|
+
}
|
|
1998
|
+
],
|
|
1999
|
+
verdict: "block",
|
|
2000
|
+
reason: "kubectl delete --all is irreversible \u2014 blocked by k8s shield"
|
|
2001
|
+
},
|
|
2002
|
+
{
|
|
2003
|
+
name: "shield:k8s:block-helm-uninstall",
|
|
2004
|
+
tool: "*",
|
|
2005
|
+
conditions: [
|
|
2006
|
+
{
|
|
2007
|
+
field: "command",
|
|
2008
|
+
op: "matches",
|
|
2009
|
+
value: "helm\\s+(uninstall|delete|del)\\s+",
|
|
2010
|
+
flags: "i"
|
|
2011
|
+
}
|
|
2012
|
+
],
|
|
2013
|
+
verdict: "block",
|
|
2014
|
+
reason: "helm uninstall removes a release and its resources \u2014 blocked by k8s shield"
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
name: "shield:k8s:review-scale-zero",
|
|
2018
|
+
tool: "*",
|
|
2019
|
+
conditions: [
|
|
2020
|
+
{
|
|
2021
|
+
field: "command",
|
|
2022
|
+
op: "matches",
|
|
2023
|
+
value: "kubectl\\s+scale\\s+.*--replicas=0",
|
|
2024
|
+
flags: "i"
|
|
2025
|
+
}
|
|
2026
|
+
],
|
|
2027
|
+
verdict: "review",
|
|
2028
|
+
reason: "Scaling to zero takes down a workload and requires human approval (k8s shield)"
|
|
2029
|
+
},
|
|
2030
|
+
{
|
|
2031
|
+
name: "shield:k8s:review-delete-deployment",
|
|
2032
|
+
tool: "*",
|
|
2033
|
+
conditions: [
|
|
2034
|
+
{
|
|
2035
|
+
field: "command",
|
|
2036
|
+
op: "matches",
|
|
2037
|
+
value: "kubectl\\s+delete\\s+(deployment|deploy|statefulset|sts|daemonset|ds)\\s+",
|
|
2038
|
+
flags: "i"
|
|
2039
|
+
}
|
|
2040
|
+
],
|
|
2041
|
+
verdict: "review",
|
|
2042
|
+
reason: "Deleting a workload requires human approval (k8s shield)"
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
name: "shield:k8s:review-apply-force",
|
|
2046
|
+
tool: "*",
|
|
2047
|
+
conditions: [
|
|
2048
|
+
{
|
|
2049
|
+
field: "command",
|
|
2050
|
+
op: "matches",
|
|
2051
|
+
value: "kubectl\\s+(apply|replace)\\s+.*--force",
|
|
2052
|
+
flags: "i"
|
|
2053
|
+
}
|
|
2054
|
+
],
|
|
2055
|
+
verdict: "review",
|
|
2056
|
+
reason: "Force-apply overwrites live resources and requires human approval (k8s shield)"
|
|
2057
|
+
}
|
|
2058
|
+
],
|
|
2059
|
+
dangerousWords: []
|
|
2060
|
+
};
|
|
2061
|
+
var mcp_tool_gating_default = {
|
|
2062
|
+
name: "mcp-tool-gating",
|
|
2063
|
+
description: "Intercept MCP tool lists and require user approval before the agent can use any tools from a new server",
|
|
2064
|
+
aliases: ["mcp-gating", "mcp-tools"],
|
|
2065
|
+
smartRules: [],
|
|
2066
|
+
dangerousWords: []
|
|
2067
|
+
};
|
|
2068
|
+
var mongodb_default = {
|
|
2069
|
+
name: "mongodb",
|
|
2070
|
+
description: "Protects MongoDB databases from destructive AI operations",
|
|
2071
|
+
aliases: ["mongo"],
|
|
2072
|
+
smartRules: [
|
|
2073
|
+
{
|
|
2074
|
+
name: "shield:mongodb:block-drop-database",
|
|
2075
|
+
tool: "*",
|
|
2076
|
+
conditions: [
|
|
2077
|
+
{
|
|
2078
|
+
field: "command",
|
|
2079
|
+
op: "matches",
|
|
2080
|
+
value: "\\.dropDatabase\\s*\\(",
|
|
2081
|
+
flags: "i"
|
|
2082
|
+
}
|
|
2083
|
+
],
|
|
2084
|
+
verdict: "block",
|
|
2085
|
+
reason: "dropDatabase is irreversible \u2014 blocked by MongoDB shield"
|
|
2086
|
+
},
|
|
2087
|
+
{
|
|
2088
|
+
name: "shield:mongodb:block-drop-collection",
|
|
2089
|
+
tool: "*",
|
|
2090
|
+
conditions: [
|
|
2091
|
+
{
|
|
2092
|
+
field: "command",
|
|
2093
|
+
op: "matches",
|
|
2094
|
+
value: "\\.drop\\s*\\(|db\\.getCollection\\([^)]+\\)\\.drop\\s*\\(",
|
|
2095
|
+
flags: "i"
|
|
2096
|
+
}
|
|
2097
|
+
],
|
|
2098
|
+
verdict: "block",
|
|
2099
|
+
reason: "Collection drop is irreversible \u2014 blocked by MongoDB shield"
|
|
2100
|
+
},
|
|
2101
|
+
{
|
|
2102
|
+
name: "shield:mongodb:block-delete-many-empty-filter",
|
|
2103
|
+
tool: "*",
|
|
2104
|
+
conditions: [
|
|
2105
|
+
{
|
|
2106
|
+
field: "command",
|
|
2107
|
+
op: "matches",
|
|
2108
|
+
value: "\\.deleteMany\\s*\\(\\s*\\{\\s*\\}\\s*\\)",
|
|
2109
|
+
flags: "i"
|
|
2110
|
+
}
|
|
2111
|
+
],
|
|
2112
|
+
verdict: "block",
|
|
2113
|
+
reason: "deleteMany({}) with empty filter wipes the entire collection \u2014 blocked by MongoDB shield"
|
|
2114
|
+
},
|
|
2115
|
+
{
|
|
2116
|
+
name: "shield:mongodb:review-delete-many",
|
|
2117
|
+
tool: "*",
|
|
2118
|
+
conditions: [
|
|
2119
|
+
{
|
|
2120
|
+
field: "command",
|
|
2121
|
+
op: "matches",
|
|
2122
|
+
value: "\\.deleteMany\\s*\\(",
|
|
2123
|
+
flags: "i"
|
|
2124
|
+
}
|
|
2125
|
+
],
|
|
2126
|
+
verdict: "review",
|
|
2127
|
+
reason: "deleteMany requires human approval (MongoDB shield)"
|
|
2128
|
+
},
|
|
2129
|
+
{
|
|
2130
|
+
name: "shield:mongodb:review-drop-index",
|
|
2131
|
+
tool: "*",
|
|
2132
|
+
conditions: [
|
|
2133
|
+
{
|
|
2134
|
+
field: "command",
|
|
2135
|
+
op: "matches",
|
|
2136
|
+
value: "\\.dropIndex\\s*\\(|\\.dropIndexes\\s*\\(",
|
|
2137
|
+
flags: "i"
|
|
2138
|
+
}
|
|
2139
|
+
],
|
|
2140
|
+
verdict: "review",
|
|
2141
|
+
reason: "Index drops affect query performance and require human approval (MongoDB shield)"
|
|
1922
2142
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2143
|
+
],
|
|
2144
|
+
dangerousWords: ["dropDatabase", "dropCollection", "mongodrop"]
|
|
2145
|
+
};
|
|
2146
|
+
var postgres_default = {
|
|
2147
|
+
name: "postgres",
|
|
2148
|
+
description: "Protects PostgreSQL databases from destructive AI operations",
|
|
2149
|
+
aliases: ["pg", "postgresql"],
|
|
2150
|
+
smartRules: [
|
|
2151
|
+
{
|
|
2152
|
+
name: "shield:postgres:block-drop-table",
|
|
2153
|
+
tool: "*",
|
|
2154
|
+
conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
|
|
2155
|
+
verdict: "block",
|
|
2156
|
+
reason: "DROP TABLE is irreversible \u2014 blocked by Postgres shield"
|
|
2157
|
+
},
|
|
2158
|
+
{
|
|
2159
|
+
name: "shield:postgres:block-truncate",
|
|
2160
|
+
tool: "*",
|
|
2161
|
+
conditions: [
|
|
2162
|
+
{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }
|
|
2163
|
+
],
|
|
2164
|
+
verdict: "block",
|
|
2165
|
+
reason: "TRUNCATE is irreversible \u2014 blocked by Postgres shield"
|
|
2166
|
+
},
|
|
2167
|
+
{
|
|
2168
|
+
name: "shield:postgres:block-drop-column",
|
|
2169
|
+
tool: "*",
|
|
2170
|
+
conditions: [
|
|
2171
|
+
{ field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
|
|
2172
|
+
],
|
|
2173
|
+
verdict: "block",
|
|
2174
|
+
reason: "DROP COLUMN is irreversible \u2014 blocked by Postgres shield"
|
|
2175
|
+
},
|
|
2176
|
+
{
|
|
2177
|
+
name: "shield:postgres:review-grant-revoke",
|
|
2178
|
+
tool: "*",
|
|
2179
|
+
conditions: [
|
|
2180
|
+
{ field: "sql", op: "matches", value: "\\b(GRANT|REVOKE)\\b", flags: "i" }
|
|
2181
|
+
],
|
|
2182
|
+
verdict: "review",
|
|
2183
|
+
reason: "Permission changes require human approval (Postgres shield)"
|
|
1926
2184
|
}
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2185
|
+
],
|
|
2186
|
+
dangerousWords: ["dropdb", "pg_dropcluster"]
|
|
2187
|
+
};
|
|
2188
|
+
var project_jail_default = {
|
|
2189
|
+
name: "project-jail",
|
|
2190
|
+
description: "Restricts AI agents from reading sensitive credential files outside the current project",
|
|
2191
|
+
aliases: ["jail"],
|
|
2192
|
+
smartRules: [
|
|
2193
|
+
{
|
|
2194
|
+
name: "shield:project-jail:block-read-ssh",
|
|
2195
|
+
tool: "bash",
|
|
2196
|
+
conditions: [
|
|
2197
|
+
{
|
|
2198
|
+
field: "command",
|
|
2199
|
+
op: "matches",
|
|
2200
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*[\\/\\\\]\\.ssh[\\/\\\\]",
|
|
2201
|
+
flags: "i"
|
|
2202
|
+
}
|
|
2203
|
+
],
|
|
2204
|
+
verdict: "block",
|
|
2205
|
+
reason: "Reading SSH private keys is blocked by project-jail shield"
|
|
2206
|
+
},
|
|
2207
|
+
{
|
|
2208
|
+
name: "shield:project-jail:block-read-aws",
|
|
2209
|
+
tool: "bash",
|
|
2210
|
+
conditions: [
|
|
2211
|
+
{
|
|
2212
|
+
field: "command",
|
|
2213
|
+
op: "matches",
|
|
2214
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*[\\/\\\\]\\.aws[\\/\\\\]",
|
|
2215
|
+
flags: "i"
|
|
2216
|
+
}
|
|
2217
|
+
],
|
|
2218
|
+
verdict: "block",
|
|
2219
|
+
reason: "Reading AWS credentials is blocked by project-jail shield"
|
|
2220
|
+
},
|
|
2221
|
+
{
|
|
2222
|
+
name: "shield:project-jail:block-read-env",
|
|
2223
|
+
tool: "bash",
|
|
2224
|
+
conditions: [
|
|
2225
|
+
{
|
|
2226
|
+
field: "command",
|
|
2227
|
+
op: "matches",
|
|
2228
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*\\.env(\\.local|\\.production|\\.staging)?\\b",
|
|
2229
|
+
flags: "i"
|
|
2230
|
+
}
|
|
2231
|
+
],
|
|
2232
|
+
verdict: "block",
|
|
2233
|
+
reason: "Reading .env files is blocked by project-jail shield"
|
|
2234
|
+
},
|
|
2235
|
+
{
|
|
2236
|
+
name: "shield:project-jail:block-read-credentials",
|
|
2237
|
+
tool: "bash",
|
|
2238
|
+
conditions: [
|
|
2239
|
+
{
|
|
2240
|
+
field: "command",
|
|
2241
|
+
op: "matches",
|
|
2242
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
|
|
2243
|
+
flags: "i"
|
|
2244
|
+
}
|
|
2245
|
+
],
|
|
2246
|
+
verdict: "block",
|
|
2247
|
+
reason: "Reading credential files is blocked by project-jail shield"
|
|
1976
2248
|
}
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
}
|
|
1995
|
-
function getNestedValue(obj, path15) {
|
|
1996
|
-
if (!obj || typeof obj !== "object") return null;
|
|
1997
|
-
return path15.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
1998
|
-
}
|
|
1999
|
-
var { syntax } = mvdanSh;
|
|
2000
|
-
var sharedParser = syntax.NewParser();
|
|
2001
|
-
var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
|
|
2002
|
-
"-m",
|
|
2003
|
-
"--message",
|
|
2004
|
-
"--body",
|
|
2005
|
-
"--title",
|
|
2006
|
-
"--description",
|
|
2007
|
-
"--comment",
|
|
2008
|
-
"--subject",
|
|
2009
|
-
"--summary"
|
|
2010
|
-
]);
|
|
2011
|
-
function normalizeCommandForPolicy(command) {
|
|
2012
|
-
try {
|
|
2013
|
-
const f = sharedParser.Parse(command, "cmd");
|
|
2014
|
-
const strips = [];
|
|
2015
|
-
syntax.Walk(f, (node) => {
|
|
2016
|
-
if (!node) return false;
|
|
2017
|
-
const n = node;
|
|
2018
|
-
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
2019
|
-
const args = n.Args || [];
|
|
2020
|
-
for (let i = 0; i < args.length - 1; i++) {
|
|
2021
|
-
const argParts = args[i].Parts || [];
|
|
2022
|
-
if (argParts.length !== 1 || syntax.NodeType(argParts[0]) !== "Lit") continue;
|
|
2023
|
-
const flagVal = argParts[0].Value || "";
|
|
2024
|
-
if (!MESSAGE_FLAGS.has(flagVal.toLowerCase())) continue;
|
|
2025
|
-
const next = args[i + 1];
|
|
2026
|
-
const nextParts = next.Parts || [];
|
|
2027
|
-
if (nextParts.length !== 1) continue;
|
|
2028
|
-
const quotedNode = nextParts[0];
|
|
2029
|
-
const nt = syntax.NodeType(quotedNode);
|
|
2030
|
-
if (nt === "SglQuoted") {
|
|
2031
|
-
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
2032
|
-
} else if (nt === "DblQuoted") {
|
|
2033
|
-
const innerParts = quotedNode.Parts || [];
|
|
2034
|
-
const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
|
|
2035
|
-
if (allLit) strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
2249
|
+
],
|
|
2250
|
+
dangerousWords: []
|
|
2251
|
+
};
|
|
2252
|
+
var redis_default = {
|
|
2253
|
+
name: "redis",
|
|
2254
|
+
description: "Protects Redis instances from destructive AI operations",
|
|
2255
|
+
aliases: [],
|
|
2256
|
+
smartRules: [
|
|
2257
|
+
{
|
|
2258
|
+
name: "shield:redis:block-flushall",
|
|
2259
|
+
tool: "*",
|
|
2260
|
+
conditions: [
|
|
2261
|
+
{
|
|
2262
|
+
field: "command",
|
|
2263
|
+
op: "matches",
|
|
2264
|
+
value: "\\bFLUSHALL\\b",
|
|
2265
|
+
flags: "i"
|
|
2036
2266
|
}
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2267
|
+
],
|
|
2268
|
+
verdict: "block",
|
|
2269
|
+
reason: "FLUSHALL deletes every key in every database \u2014 blocked by Redis shield"
|
|
2270
|
+
},
|
|
2271
|
+
{
|
|
2272
|
+
name: "shield:redis:block-flushdb",
|
|
2273
|
+
tool: "*",
|
|
2274
|
+
conditions: [
|
|
2275
|
+
{
|
|
2276
|
+
field: "command",
|
|
2277
|
+
op: "matches",
|
|
2278
|
+
value: "\\bFLUSHDB\\b",
|
|
2279
|
+
flags: "i"
|
|
2280
|
+
}
|
|
2281
|
+
],
|
|
2282
|
+
verdict: "block",
|
|
2283
|
+
reason: "FLUSHDB deletes all keys in the current database \u2014 blocked by Redis shield"
|
|
2284
|
+
},
|
|
2285
|
+
{
|
|
2286
|
+
name: "shield:redis:block-config-resetstat",
|
|
2287
|
+
tool: "*",
|
|
2288
|
+
conditions: [
|
|
2289
|
+
{
|
|
2290
|
+
field: "command",
|
|
2291
|
+
op: "matches",
|
|
2292
|
+
value: "\\bCONFIG\\s+RESETSTAT\\b",
|
|
2293
|
+
flags: "i"
|
|
2294
|
+
}
|
|
2295
|
+
],
|
|
2296
|
+
verdict: "block",
|
|
2297
|
+
reason: "CONFIG RESETSTAT resets server statistics irreversibly \u2014 blocked by Redis shield"
|
|
2298
|
+
},
|
|
2299
|
+
{
|
|
2300
|
+
name: "shield:redis:review-config-set",
|
|
2301
|
+
tool: "*",
|
|
2302
|
+
conditions: [
|
|
2303
|
+
{
|
|
2304
|
+
field: "command",
|
|
2305
|
+
op: "matches",
|
|
2306
|
+
value: "\\bCONFIG\\s+SET\\b",
|
|
2307
|
+
flags: "i"
|
|
2308
|
+
}
|
|
2309
|
+
],
|
|
2310
|
+
verdict: "review",
|
|
2311
|
+
reason: "CONFIG SET changes live server configuration and requires human approval (Redis shield)"
|
|
2312
|
+
},
|
|
2313
|
+
{
|
|
2314
|
+
name: "shield:redis:review-del-wildcard",
|
|
2315
|
+
tool: "*",
|
|
2316
|
+
conditions: [
|
|
2317
|
+
{
|
|
2318
|
+
field: "command",
|
|
2319
|
+
op: "matches",
|
|
2320
|
+
value: "\\bDEL\\b.*[*?\\[]|redis-cli.*--scan.*\\|.*xargs.*del",
|
|
2321
|
+
flags: "i"
|
|
2322
|
+
}
|
|
2323
|
+
],
|
|
2324
|
+
verdict: "review",
|
|
2325
|
+
reason: "Wildcard key deletion requires human approval (Redis shield)"
|
|
2045
2326
|
}
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
var SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
|
|
2052
|
-
var DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
|
|
2053
|
-
function scanArgsForDynamicExec(args, startIdx) {
|
|
2054
|
-
let hasCmdSubst = false;
|
|
2055
|
-
let hasParamExp = false;
|
|
2056
|
-
let hasCurl = false;
|
|
2057
|
-
for (let i = startIdx; i < args.length; i++) {
|
|
2058
|
-
syntax.Walk(args[i], (inner) => {
|
|
2059
|
-
if (!inner) return false;
|
|
2060
|
-
const inn = inner;
|
|
2061
|
-
const it = syntax.NodeType(inn);
|
|
2062
|
-
if (it === "CmdSubst") hasCmdSubst = true;
|
|
2063
|
-
if (it === "ParamExp") hasParamExp = true;
|
|
2064
|
-
if (it === "Lit" && DOWNLOAD_CMDS.has(inn.Value?.toLowerCase())) hasCurl = true;
|
|
2065
|
-
return true;
|
|
2066
|
-
});
|
|
2067
|
-
}
|
|
2068
|
-
if (hasCmdSubst && hasCurl) return "block";
|
|
2069
|
-
if (hasCmdSubst || hasParamExp) return "review";
|
|
2070
|
-
return null;
|
|
2327
|
+
],
|
|
2328
|
+
dangerousWords: ["FLUSHALL", "FLUSHDB"]
|
|
2329
|
+
};
|
|
2330
|
+
function isShieldVerdict(v) {
|
|
2331
|
+
return v === "allow" || v === "review" || v === "block";
|
|
2071
2332
|
}
|
|
2072
|
-
function
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
let result = null;
|
|
2076
|
-
syntax.Walk(f, (node) => {
|
|
2077
|
-
if (!node || result === "block") return false;
|
|
2078
|
-
const n = node;
|
|
2079
|
-
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
2080
|
-
const args = n.Args || [];
|
|
2081
|
-
if (args.length === 0) return true;
|
|
2082
|
-
const firstParts = args[0].Parts || [];
|
|
2083
|
-
if (firstParts.length !== 1 || syntax.NodeType(firstParts[0]) !== "Lit") return true;
|
|
2084
|
-
const cmdName = firstParts[0].Value?.toLowerCase() ?? "";
|
|
2085
|
-
if (cmdName === "eval") {
|
|
2086
|
-
const v = scanArgsForDynamicExec(args, 1);
|
|
2087
|
-
if (v === "block" || v === "review" && result === null) result = v;
|
|
2088
|
-
} else if (SHELL_INTERPRETERS.has(cmdName)) {
|
|
2089
|
-
for (let i = 1; i < args.length - 1; i++) {
|
|
2090
|
-
const flagParts = args[i].Parts || [];
|
|
2091
|
-
if (flagParts.length !== 1 || syntax.NodeType(flagParts[0]) !== "Lit" || flagParts[0].Value !== "-c")
|
|
2092
|
-
continue;
|
|
2093
|
-
const v = scanArgsForDynamicExec(args, i + 1);
|
|
2094
|
-
if (v === "block" || v === "review" && result === null) result = v;
|
|
2095
|
-
break;
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
return true;
|
|
2099
|
-
});
|
|
2100
|
-
return result;
|
|
2101
|
-
} catch {
|
|
2102
|
-
return null;
|
|
2333
|
+
function validateShieldDefinition(raw) {
|
|
2334
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2335
|
+
return { error: "Shield file is not an object" };
|
|
2103
2336
|
}
|
|
2337
|
+
const r = raw;
|
|
2338
|
+
if (typeof r.name !== "string" || !r.name) return { error: "Shield file missing 'name'" };
|
|
2339
|
+
if (typeof r.description !== "string") return { error: "Shield file missing 'description'" };
|
|
2340
|
+
if (!Array.isArray(r.aliases)) return { error: "Shield file missing 'aliases' array" };
|
|
2341
|
+
if (!Array.isArray(r.smartRules)) return { error: "Shield file missing 'smartRules' array" };
|
|
2342
|
+
if (!Array.isArray(r.dangerousWords))
|
|
2343
|
+
return { error: "Shield file missing 'dangerousWords' array" };
|
|
2344
|
+
return { ok: r };
|
|
2104
2345
|
}
|
|
2105
|
-
function
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
const
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
const
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
case "notContains":
|
|
2120
|
-
return val !== null && cond.value ? !val.includes(cond.value) : true;
|
|
2121
|
-
case "matches": {
|
|
2122
|
-
if (val === null || !cond.value) return false;
|
|
2123
|
-
const reM = getCompiledRegex(cond.value, cond.flags ?? "");
|
|
2124
|
-
if (!reM) return false;
|
|
2125
|
-
return reM.test(val);
|
|
2346
|
+
function validateOverrides(raw) {
|
|
2347
|
+
const warnings = [];
|
|
2348
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { overrides: {}, warnings };
|
|
2349
|
+
const result = {};
|
|
2350
|
+
for (const [shieldName, rules] of Object.entries(raw)) {
|
|
2351
|
+
if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
|
|
2352
|
+
const validRules = {};
|
|
2353
|
+
for (const [ruleName, verdict] of Object.entries(rules)) {
|
|
2354
|
+
if (isShieldVerdict(verdict)) {
|
|
2355
|
+
validRules[ruleName] = verdict;
|
|
2356
|
+
} else {
|
|
2357
|
+
warnings.push(
|
|
2358
|
+
`shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.`
|
|
2359
|
+
);
|
|
2126
2360
|
}
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2361
|
+
}
|
|
2362
|
+
if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
|
|
2363
|
+
}
|
|
2364
|
+
return { overrides: result, warnings };
|
|
2365
|
+
}
|
|
2366
|
+
var BUILTIN_SHIELDS = {
|
|
2367
|
+
[aws_default.name]: aws_default,
|
|
2368
|
+
[bash_safe_default.name]: bash_safe_default,
|
|
2369
|
+
[docker_default.name]: docker_default,
|
|
2370
|
+
[filesystem_default.name]: filesystem_default,
|
|
2371
|
+
[github_default.name]: github_default,
|
|
2372
|
+
[k8s_default.name]: k8s_default,
|
|
2373
|
+
[mcp_tool_gating_default.name]: mcp_tool_gating_default,
|
|
2374
|
+
[mongodb_default.name]: mongodb_default,
|
|
2375
|
+
[postgres_default.name]: postgres_default,
|
|
2376
|
+
[project_jail_default.name]: project_jail_default,
|
|
2377
|
+
[redis_default.name]: redis_default
|
|
2378
|
+
};
|
|
2379
|
+
function assertBuiltinShieldRegexesAreSafe() {
|
|
2380
|
+
for (const shield of Object.values(BUILTIN_SHIELDS)) {
|
|
2381
|
+
for (const rule of shield.smartRules) {
|
|
2382
|
+
const conditions = rule.conditions ?? [];
|
|
2383
|
+
for (const cond of conditions) {
|
|
2384
|
+
if (cond.op !== "matches" && cond.op !== "notMatches") continue;
|
|
2385
|
+
const pattern = cond.value;
|
|
2386
|
+
if (!pattern) continue;
|
|
2387
|
+
if (!safeRegex3(pattern)) {
|
|
2388
|
+
throw new Error(
|
|
2389
|
+
`[node9 engine] Shield '${shield.name}' rule '${rule.name ?? rule.tool}' has unsafe regex: ${pattern}`
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2133
2392
|
}
|
|
2134
|
-
case "matchesGlob":
|
|
2135
|
-
return val !== null && cond.value ? pm.isMatch(val, cond.value) : false;
|
|
2136
|
-
case "notMatchesGlob":
|
|
2137
|
-
return val !== null && cond.value ? !pm.isMatch(val, cond.value) : false;
|
|
2138
|
-
default:
|
|
2139
|
-
return false;
|
|
2140
2393
|
}
|
|
2141
|
-
}
|
|
2142
|
-
return mode === "any" ? results.some((r) => r) : results.every((r) => r);
|
|
2394
|
+
}
|
|
2143
2395
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
const value = getNestedValue(args, fieldPath);
|
|
2150
|
-
return typeof value === "string" ? value : null;
|
|
2396
|
+
assertBuiltinShieldRegexesAreSafe();
|
|
2397
|
+
var LOOP_MAX_RECORDS = 500;
|
|
2398
|
+
function computeArgsHash(args) {
|
|
2399
|
+
const str = JSON.stringify(args ?? "");
|
|
2400
|
+
return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
2151
2401
|
}
|
|
2152
|
-
function
|
|
2153
|
-
const
|
|
2154
|
-
const
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2402
|
+
function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
|
|
2403
|
+
const hash = computeArgsHash(args);
|
|
2404
|
+
const cutoff = now - windowMs;
|
|
2405
|
+
const fresh = records.filter((r) => r.ts >= cutoff);
|
|
2406
|
+
fresh.push({ t: tool, h: hash, ts: now });
|
|
2407
|
+
const count = fresh.filter((r) => r.t === tool && r.h === hash).length;
|
|
2408
|
+
const nextRecords = fresh.slice(-LOOP_MAX_RECORDS);
|
|
2409
|
+
return { nextRecords, count, looping: count >= threshold };
|
|
2158
2410
|
}
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
const
|
|
2164
|
-
|
|
2165
|
-
const lower = token.toLowerCase();
|
|
2166
|
-
allTokens.push(lower);
|
|
2167
|
-
if (lower.includes("/")) allTokens.push(...lower.split("/").filter(Boolean));
|
|
2168
|
-
if (lower.startsWith("-")) allTokens.push(lower.replace(/^-+/, ""));
|
|
2169
|
-
};
|
|
2411
|
+
|
|
2412
|
+
// src/shields.ts
|
|
2413
|
+
var USER_SHIELDS_DIR = path2.join(os2.homedir(), ".node9", "shields");
|
|
2414
|
+
function loadUserShields() {
|
|
2415
|
+
const result = {};
|
|
2416
|
+
let entries;
|
|
2170
2417
|
try {
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2418
|
+
entries = fs2.readdirSync(USER_SHIELDS_DIR).filter((f) => f.endsWith(".json"));
|
|
2419
|
+
} catch (err) {
|
|
2420
|
+
if (err.code !== "ENOENT") {
|
|
2421
|
+
process.stderr.write(
|
|
2422
|
+
`[node9] Could not read user shields dir ${USER_SHIELDS_DIR}: ${String(err)}
|
|
2423
|
+
`
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
return result;
|
|
2427
|
+
}
|
|
2428
|
+
for (const file of entries) {
|
|
2429
|
+
const filePath = path2.join(USER_SHIELDS_DIR, file);
|
|
2430
|
+
try {
|
|
2431
|
+
const raw = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
|
|
2432
|
+
const v = validateShieldDefinition(raw);
|
|
2433
|
+
if ("error" in v) {
|
|
2434
|
+
process.stderr.write(`[node9] ${v.error}: ${filePath}
|
|
2435
|
+
`);
|
|
2436
|
+
continue;
|
|
2186
2437
|
}
|
|
2187
|
-
|
|
2188
|
-
})
|
|
2189
|
-
|
|
2438
|
+
result[v.ok.name] = v.ok;
|
|
2439
|
+
} catch (err) {
|
|
2440
|
+
process.stderr.write(`[node9] Failed to load user shield ${file}: ${String(err)}
|
|
2441
|
+
`);
|
|
2442
|
+
}
|
|
2190
2443
|
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2444
|
+
return result;
|
|
2445
|
+
}
|
|
2446
|
+
function buildSHIELDS() {
|
|
2447
|
+
return { ...BUILTIN_SHIELDS, ...loadUserShields() };
|
|
2448
|
+
}
|
|
2449
|
+
var SHIELDS = buildSHIELDS();
|
|
2450
|
+
function resolveShieldName(input) {
|
|
2451
|
+
const lower = input.toLowerCase();
|
|
2452
|
+
if (SHIELDS[lower]) return lower;
|
|
2453
|
+
for (const [name, def] of Object.entries(SHIELDS)) {
|
|
2454
|
+
if (def.aliases.includes(lower)) return name;
|
|
2455
|
+
}
|
|
2456
|
+
return null;
|
|
2457
|
+
}
|
|
2458
|
+
function getShield(name) {
|
|
2459
|
+
const resolved = resolveShieldName(name);
|
|
2460
|
+
return resolved ? SHIELDS[resolved] : null;
|
|
2461
|
+
}
|
|
2462
|
+
var SHIELDS_STATE_FILE = path2.join(os2.homedir(), ".node9", "shields.json");
|
|
2463
|
+
function readShieldsFile() {
|
|
2464
|
+
try {
|
|
2465
|
+
const raw = fs2.readFileSync(SHIELDS_STATE_FILE, "utf-8");
|
|
2466
|
+
if (!raw.trim()) return { active: [] };
|
|
2467
|
+
const parsed = JSON.parse(raw);
|
|
2468
|
+
const active = Array.isArray(parsed.active) ? parsed.active.filter(
|
|
2469
|
+
(e) => typeof e === "string" && e.length > 0 && e in SHIELDS
|
|
2470
|
+
) : [];
|
|
2471
|
+
const { overrides, warnings } = validateOverrides(parsed.overrides);
|
|
2472
|
+
for (const w of warnings) {
|
|
2473
|
+
process.stderr.write(`[node9] Warning: ${w}
|
|
2474
|
+
`);
|
|
2475
|
+
}
|
|
2476
|
+
return { active, overrides };
|
|
2477
|
+
} catch (err) {
|
|
2478
|
+
if (err.code !== "ENOENT") {
|
|
2479
|
+
process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
|
|
2480
|
+
`);
|
|
2481
|
+
}
|
|
2482
|
+
return { active: [] };
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
function readActiveShields() {
|
|
2486
|
+
return readShieldsFile().active;
|
|
2487
|
+
}
|
|
2488
|
+
function readShieldOverrides() {
|
|
2489
|
+
return readShieldsFile().overrides ?? {};
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
// src/config/index.ts
|
|
2493
|
+
var DANGEROUS_WORDS = [
|
|
2494
|
+
"mkfs",
|
|
2495
|
+
// formats/wipes a filesystem partition
|
|
2496
|
+
"shred"
|
|
2497
|
+
// permanently overwrites file contents (unrecoverable)
|
|
2498
|
+
];
|
|
2499
|
+
var DEFAULT_CONFIG = {
|
|
2500
|
+
version: "1.0",
|
|
2501
|
+
settings: {
|
|
2502
|
+
mode: "standard",
|
|
2503
|
+
autoStartDaemon: true,
|
|
2504
|
+
enableUndo: true,
|
|
2505
|
+
// 🔥 ALWAYS TRUE BY DEFAULT for the safety net
|
|
2506
|
+
enableHookLogDebug: true,
|
|
2507
|
+
approvalTimeoutMs: 12e4,
|
|
2508
|
+
// 120-second auto-deny timeout
|
|
2509
|
+
flightRecorder: true,
|
|
2510
|
+
auditHashArgs: true,
|
|
2511
|
+
approvers: { native: true, browser: false, cloud: false, terminal: true },
|
|
2512
|
+
cloudSyncIntervalHours: 5
|
|
2513
|
+
},
|
|
2514
|
+
policy: {
|
|
2515
|
+
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
2516
|
+
dangerousWords: DANGEROUS_WORDS,
|
|
2517
|
+
ignoredTools: [
|
|
2518
|
+
"list_*",
|
|
2519
|
+
"get_*",
|
|
2520
|
+
"read_*",
|
|
2521
|
+
"describe_*",
|
|
2522
|
+
"read",
|
|
2523
|
+
"glob",
|
|
2524
|
+
"grep",
|
|
2525
|
+
"ls",
|
|
2526
|
+
"notebookread",
|
|
2527
|
+
"notebookedit",
|
|
2528
|
+
"webfetch",
|
|
2529
|
+
"websearch",
|
|
2530
|
+
"exitplanmode",
|
|
2531
|
+
"askuserquestion",
|
|
2532
|
+
"agent",
|
|
2533
|
+
"task*",
|
|
2534
|
+
"toolsearch",
|
|
2535
|
+
"mcp__ide__*",
|
|
2536
|
+
"getDiagnostics"
|
|
2537
|
+
],
|
|
2538
|
+
toolInspection: {
|
|
2539
|
+
bash: "command",
|
|
2540
|
+
shell: "command",
|
|
2541
|
+
run_shell_command: "command",
|
|
2542
|
+
"terminal.execute": "command",
|
|
2543
|
+
"postgres:query": "sql"
|
|
2544
|
+
},
|
|
2545
|
+
snapshot: {
|
|
2546
|
+
tools: [
|
|
2547
|
+
"str_replace_based_edit_tool",
|
|
2548
|
+
"write_file",
|
|
2549
|
+
"edit_file",
|
|
2550
|
+
"create_file",
|
|
2551
|
+
"edit",
|
|
2552
|
+
"replace"
|
|
2553
|
+
],
|
|
2554
|
+
onlyPaths: [],
|
|
2555
|
+
ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
|
|
2556
|
+
},
|
|
2557
|
+
smartRules: [
|
|
2558
|
+
// ── rm safety (critical — always evaluated first) ──────────────────────
|
|
2559
|
+
{
|
|
2560
|
+
name: "block-rm-rf-home",
|
|
2561
|
+
tool: "bash",
|
|
2562
|
+
conditionMode: "all",
|
|
2563
|
+
conditions: [
|
|
2564
|
+
{
|
|
2565
|
+
field: "command",
|
|
2566
|
+
op: "matches",
|
|
2567
|
+
// Anchor rm as a shell command (not inside a string arg like a git commit message).
|
|
2568
|
+
value: "(^|&&|\\|\\||;)\\s*rm\\b[^;&|]*\\s(-[rRfF]*[rR][rRfF]*|--recursive)(\\s|$)"
|
|
2569
|
+
},
|
|
2570
|
+
{
|
|
2571
|
+
field: "command",
|
|
2572
|
+
op: "matches",
|
|
2573
|
+
value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
|
|
2574
|
+
}
|
|
2575
|
+
],
|
|
2576
|
+
verdict: "block",
|
|
2577
|
+
reason: "Recursive delete of home directory is irreversible",
|
|
2578
|
+
description: "The AI wants to recursively delete your home directory. This will permanently destroy all your personal files and cannot be undone."
|
|
2579
|
+
},
|
|
2580
|
+
// ── SQL safety ────────────────────────────────────────────────────────
|
|
2581
|
+
{
|
|
2582
|
+
name: "no-delete-without-where",
|
|
2583
|
+
tool: "*",
|
|
2584
|
+
conditions: [
|
|
2585
|
+
{ field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
|
|
2586
|
+
{ field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
|
|
2587
|
+
],
|
|
2588
|
+
conditionMode: "all",
|
|
2589
|
+
verdict: "review",
|
|
2590
|
+
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table",
|
|
2591
|
+
description: "The AI is running a SQL statement that will modify every row in the table \u2014 no WHERE filter was found. This could wipe or corrupt all your data."
|
|
2592
|
+
},
|
|
2593
|
+
{
|
|
2594
|
+
name: "review-drop-truncate-shell",
|
|
2595
|
+
tool: "bash",
|
|
2596
|
+
conditions: [
|
|
2597
|
+
{
|
|
2598
|
+
field: "command",
|
|
2599
|
+
op: "matches",
|
|
2600
|
+
// Require a DB CLI in the command so grep/cat/echo of SQL strings don't trigger.
|
|
2601
|
+
value: "(^|&&|\\|\\||;|\\|)\\s*(psql|mysql|sqlite3|sqlplus|cockroach|clickhouse-client|mongo)\\b",
|
|
2602
|
+
flags: "i"
|
|
2603
|
+
},
|
|
2604
|
+
{
|
|
2605
|
+
field: "command",
|
|
2606
|
+
op: "matches",
|
|
2607
|
+
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
2608
|
+
flags: "i"
|
|
2204
2609
|
}
|
|
2205
|
-
|
|
2610
|
+
],
|
|
2611
|
+
conditionMode: "all",
|
|
2612
|
+
verdict: "review",
|
|
2613
|
+
reason: "SQL DDL destructive statement inside a shell command",
|
|
2614
|
+
description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
|
|
2615
|
+
},
|
|
2616
|
+
// ── Git safety ────────────────────────────────────────────────────────
|
|
2617
|
+
{
|
|
2618
|
+
name: "review-force-push",
|
|
2619
|
+
tool: "bash",
|
|
2620
|
+
conditions: [
|
|
2621
|
+
{
|
|
2622
|
+
field: "command",
|
|
2623
|
+
op: "matches",
|
|
2624
|
+
// Anchor git as a shell command so node -e / python -c scripts containing
|
|
2625
|
+
// "git push --force" as a string don't false-positive.
|
|
2626
|
+
value: "(^|&&|\\|\\||;)\\s*git\\s+push[^;&|]*(--force|--force-with-lease|-f\\b)",
|
|
2627
|
+
flags: "i"
|
|
2628
|
+
}
|
|
2629
|
+
],
|
|
2630
|
+
conditionMode: "all",
|
|
2631
|
+
verdict: "review",
|
|
2632
|
+
reason: "Force push rewrites remote history \u2014 confirm this is intentional",
|
|
2633
|
+
description: "The AI wants to force push to a remote git branch. This rewrites shared history and can permanently destroy commits that teammates have already pulled."
|
|
2634
|
+
},
|
|
2635
|
+
{
|
|
2636
|
+
name: "review-git-destructive",
|
|
2637
|
+
tool: "bash",
|
|
2638
|
+
conditions: [
|
|
2639
|
+
{
|
|
2640
|
+
field: "command",
|
|
2641
|
+
op: "matches",
|
|
2642
|
+
// Anchor git as a shell command so node -e / python -c scripts containing
|
|
2643
|
+
// "git reset --hard" as a string don't false-positive.
|
|
2644
|
+
value: "(^|&&|\\|\\||;)\\s*git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
2645
|
+
flags: "i"
|
|
2646
|
+
},
|
|
2647
|
+
{
|
|
2648
|
+
field: "command",
|
|
2649
|
+
op: "notMatches",
|
|
2650
|
+
// Exclude recovery ops and routine branch-surgery (--onto) — these are not destructive.
|
|
2651
|
+
value: "\\bgit\\s+rebase\\s+--(abort|continue|skip|onto)\\b",
|
|
2652
|
+
flags: "i"
|
|
2653
|
+
}
|
|
2654
|
+
],
|
|
2655
|
+
conditionMode: "all",
|
|
2656
|
+
verdict: "review",
|
|
2657
|
+
reason: "Destructive git operation \u2014 discards history or working-tree changes",
|
|
2658
|
+
description: "The AI wants to run a destructive git operation (reset, rebase, clean, or branch delete) that can permanently discard commits or uncommitted work."
|
|
2659
|
+
},
|
|
2660
|
+
// ── Shell safety ──────────────────────────────────────────────────────
|
|
2661
|
+
{
|
|
2662
|
+
name: "review-sudo",
|
|
2663
|
+
tool: "bash",
|
|
2664
|
+
conditions: [{ field: "command", op: "matches", value: "\\bsudo\\s", flags: "i" }],
|
|
2665
|
+
conditionMode: "all",
|
|
2666
|
+
verdict: "review",
|
|
2667
|
+
reason: "Command requires elevated privileges",
|
|
2668
|
+
description: "The AI wants to run a command as root (sudo). Commands with root access can modify system files, install software, or change security settings."
|
|
2669
|
+
},
|
|
2670
|
+
{
|
|
2671
|
+
name: "review-curl-pipe-shell",
|
|
2672
|
+
tool: "bash",
|
|
2673
|
+
conditions: [
|
|
2674
|
+
{
|
|
2675
|
+
field: "command",
|
|
2676
|
+
op: "matches",
|
|
2677
|
+
// Anchor curl/wget as a shell command so node -e scripts testing this
|
|
2678
|
+
// regex pattern don't self-match as a false positive.
|
|
2679
|
+
value: "(^|&&|\\|\\||;)\\s*(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
2680
|
+
flags: "i"
|
|
2681
|
+
}
|
|
2682
|
+
],
|
|
2683
|
+
conditionMode: "all",
|
|
2684
|
+
verdict: "block",
|
|
2685
|
+
reason: "Piping remote script into a shell is a supply-chain attack vector",
|
|
2686
|
+
description: "The AI wants to download a script from the internet and run it immediately, without you seeing what it contains. This is one of the most common ways malware gets installed."
|
|
2206
2687
|
}
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2688
|
+
],
|
|
2689
|
+
dlp: { enabled: true, scanIgnoredTools: true },
|
|
2690
|
+
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
|
|
2691
|
+
skillPinning: { enabled: false, mode: "warn", roots: [] }
|
|
2692
|
+
},
|
|
2693
|
+
environments: {}
|
|
2694
|
+
};
|
|
2695
|
+
var ADVISORY_SMART_RULES = [
|
|
2696
|
+
// ── rm safety ─────────────────────────────────────────────────────────────
|
|
2697
|
+
// tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
|
|
2698
|
+
// Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
|
|
2699
|
+
// chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
|
|
2700
|
+
{
|
|
2701
|
+
name: "allow-rm-safe-paths",
|
|
2702
|
+
tool: "*",
|
|
2703
|
+
conditionMode: "all",
|
|
2704
|
+
conditions: [
|
|
2705
|
+
{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" },
|
|
2706
|
+
{
|
|
2707
|
+
field: "command",
|
|
2708
|
+
op: "matches",
|
|
2709
|
+
// Matches known-safe build artifact paths in the command.
|
|
2710
|
+
value: "(node_modules|\\bdist\\b|\\.next|\\bcoverage\\b|\\.cache|\\btmp\\b|\\btemp\\b|\\.DS_Store)(\\/|\\s|$)"
|
|
2711
|
+
}
|
|
2712
|
+
],
|
|
2713
|
+
verdict: "allow",
|
|
2714
|
+
reason: "Deleting a known-safe build artifact path"
|
|
2715
|
+
},
|
|
2716
|
+
{
|
|
2717
|
+
name: "review-rm",
|
|
2718
|
+
tool: "*",
|
|
2719
|
+
conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
|
|
2720
|
+
verdict: "review",
|
|
2721
|
+
reason: "rm can permanently delete files \u2014 confirm the target path",
|
|
2722
|
+
description: "The AI wants to delete files. Unlike moving to trash, rm is permanent \u2014 the files cannot be recovered without a backup."
|
|
2723
|
+
},
|
|
2724
|
+
// ── SQL safety (Safe by Default) ──────────────────────────────────────────
|
|
2725
|
+
// These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
|
|
2726
|
+
// mcp__postgres__query) with a destructive SQL statement in the 'sql' field.
|
|
2727
|
+
// The postgres shield upgrades these from 'review' → 'block' for stricter teams;
|
|
2728
|
+
// without a shield, users still get a human-approval gate on every destructive op.
|
|
2729
|
+
{
|
|
2730
|
+
name: "review-drop-table-sql",
|
|
2731
|
+
tool: "*",
|
|
2732
|
+
conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
|
|
2733
|
+
verdict: "review",
|
|
2734
|
+
reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead",
|
|
2735
|
+
description: "The AI wants to drop a database table. This permanently deletes the table and all its data \u2014 there is no undo."
|
|
2736
|
+
},
|
|
2737
|
+
{
|
|
2738
|
+
name: "review-truncate-sql",
|
|
2739
|
+
tool: "*",
|
|
2740
|
+
conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
|
|
2741
|
+
verdict: "review",
|
|
2742
|
+
reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead",
|
|
2743
|
+
description: "The AI wants to truncate a database table, which instantly deletes every row. The table structure remains but all data is gone."
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
name: "review-drop-column-sql",
|
|
2747
|
+
tool: "*",
|
|
2748
|
+
conditions: [
|
|
2749
|
+
{ field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
|
|
2750
|
+
],
|
|
2751
|
+
verdict: "review",
|
|
2752
|
+
reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead",
|
|
2753
|
+
description: "The AI wants to drop a column from a database table. This permanently removes the column and all its data from every row."
|
|
2223
2754
|
}
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
decision: matchedRule.verdict,
|
|
2234
|
-
blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
|
|
2235
|
-
reason: matchedRule.reason,
|
|
2236
|
-
tier: 2,
|
|
2237
|
-
ruleName: matchedRule.name ?? matchedRule.tool,
|
|
2238
|
-
...(matchedRule.description ?? matchedRule.reason) && {
|
|
2239
|
-
ruleDescription: matchedRule.description ?? matchedRule.reason
|
|
2240
|
-
},
|
|
2241
|
-
...matchedRule.verdict === "block" && matchedRule.dependsOnState?.length && {
|
|
2242
|
-
dependsOnStatePredicates: matchedRule.dependsOnState
|
|
2243
|
-
},
|
|
2244
|
-
...matchedRule.verdict === "block" && matchedRule.recoveryCommand && {
|
|
2245
|
-
recoveryCommand: matchedRule.recoveryCommand
|
|
2246
|
-
}
|
|
2247
|
-
};
|
|
2248
|
-
}
|
|
2755
|
+
];
|
|
2756
|
+
var cachedConfig = null;
|
|
2757
|
+
function getCredentials() {
|
|
2758
|
+
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
2759
|
+
if (process.env.NODE9_API_KEY) {
|
|
2760
|
+
return {
|
|
2761
|
+
apiKey: process.env.NODE9_API_KEY,
|
|
2762
|
+
apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
|
|
2763
|
+
};
|
|
2249
2764
|
}
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
|
|
2258
|
-
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
2259
|
-
return {
|
|
2260
|
-
decision: "review",
|
|
2261
|
-
blockedByLabel: "Node9 Standard (Inline Execution)",
|
|
2262
|
-
ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
|
|
2263
|
-
tier: 3
|
|
2264
|
-
};
|
|
2265
|
-
}
|
|
2266
|
-
const evalVerdict = detectDangerousShellExec(shellCommand);
|
|
2267
|
-
if (evalVerdict === "block") {
|
|
2268
|
-
return {
|
|
2269
|
-
decision: "block",
|
|
2270
|
-
blockedByLabel: "Node9: Eval Remote Execution",
|
|
2271
|
-
reason: "eval of remote download (curl/wget) is a near-certain supply-chain attack",
|
|
2272
|
-
ruleDescription: "The AI is downloading a script from the internet and running it immediately without inspection. This is a common way malware gets installed.",
|
|
2273
|
-
tier: 3
|
|
2274
|
-
};
|
|
2275
|
-
}
|
|
2276
|
-
if (evalVerdict === "review") {
|
|
2277
|
-
return {
|
|
2278
|
-
decision: "review",
|
|
2279
|
-
blockedByLabel: "Node9: Eval Dynamic Content",
|
|
2280
|
-
reason: "eval of dynamic content (variable or subshell expansion) requires approval",
|
|
2281
|
-
ruleDescription: "The AI is running a command that includes a variable or subshell expansion. The actual command executed at runtime may differ from what is shown here.",
|
|
2282
|
-
tier: 3
|
|
2283
|
-
};
|
|
2284
|
-
}
|
|
2285
|
-
const pipeAnalysis = analyzePipeChain(shellCommand);
|
|
2286
|
-
if (pipeAnalysis.isPipeline && (pipeAnalysis.risk === "critical" || pipeAnalysis.risk === "high")) {
|
|
2287
|
-
const sinks = pipeAnalysis.sinkTargets;
|
|
2288
|
-
const allTrusted = sinks.length > 0 && sinks.every(isTrustedHost);
|
|
2289
|
-
if (pipeAnalysis.risk === "critical") {
|
|
2290
|
-
if (allTrusted) {
|
|
2291
|
-
return {
|
|
2292
|
-
decision: "review",
|
|
2293
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
2294
|
-
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
2295
|
-
tier: 3
|
|
2296
|
-
};
|
|
2297
|
-
}
|
|
2765
|
+
try {
|
|
2766
|
+
const credPath = path3.join(os3.homedir(), ".node9", "credentials.json");
|
|
2767
|
+
if (fs3.existsSync(credPath)) {
|
|
2768
|
+
const creds = JSON.parse(fs3.readFileSync(credPath, "utf-8"));
|
|
2769
|
+
const profileName = process.env.NODE9_PROFILE || "default";
|
|
2770
|
+
const profile = creds[profileName];
|
|
2771
|
+
if (profile?.apiKey) {
|
|
2298
2772
|
return {
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
2302
|
-
tier: 3
|
|
2773
|
+
apiKey: profile.apiKey,
|
|
2774
|
+
apiUrl: profile.apiUrl || DEFAULT_API_URL
|
|
2303
2775
|
};
|
|
2304
2776
|
}
|
|
2305
|
-
if (
|
|
2777
|
+
if (creds.apiKey) {
|
|
2306
2778
|
return {
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
2310
|
-
tier: 3
|
|
2779
|
+
apiKey: creds.apiKey,
|
|
2780
|
+
apiUrl: creds.apiUrl || DEFAULT_API_URL
|
|
2311
2781
|
};
|
|
2312
2782
|
}
|
|
2313
|
-
return {
|
|
2314
|
-
decision: "review",
|
|
2315
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
2316
|
-
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
2317
|
-
tier: 3
|
|
2318
|
-
};
|
|
2319
2783
|
}
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2784
|
+
} catch {
|
|
2785
|
+
}
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
function getActiveEnvironment(config) {
|
|
2789
|
+
const env = config.settings.environment || process.env.NODE_ENV || "development";
|
|
2790
|
+
return config.environments[env] ?? null;
|
|
2791
|
+
}
|
|
2792
|
+
function getConfig(cwd) {
|
|
2793
|
+
if (!cwd && cachedConfig) return cachedConfig;
|
|
2794
|
+
const globalPath = path3.join(os3.homedir(), ".node9", "config.json");
|
|
2795
|
+
const projectPath = path3.join(cwd ?? process.cwd(), "node9.config.json");
|
|
2796
|
+
const globalConfig = tryLoadConfig(globalPath);
|
|
2797
|
+
const projectConfig = tryLoadConfig(projectPath);
|
|
2798
|
+
const mergedSettings = {
|
|
2799
|
+
...DEFAULT_CONFIG.settings,
|
|
2800
|
+
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
2801
|
+
};
|
|
2802
|
+
const mergedPolicy = {
|
|
2803
|
+
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
2804
|
+
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
2805
|
+
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
2806
|
+
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
2807
|
+
smartRules: [...DEFAULT_CONFIG.policy.smartRules],
|
|
2808
|
+
snapshot: {
|
|
2809
|
+
tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
|
|
2810
|
+
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
2811
|
+
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
2812
|
+
},
|
|
2813
|
+
dlp: { ...DEFAULT_CONFIG.policy.dlp },
|
|
2814
|
+
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
|
|
2815
|
+
skillPinning: {
|
|
2816
|
+
...DEFAULT_CONFIG.policy.skillPinning,
|
|
2817
|
+
roots: [...DEFAULT_CONFIG.policy.skillPinning.roots]
|
|
2325
2818
|
}
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2819
|
+
};
|
|
2820
|
+
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
2821
|
+
const applyLayer = (source) => {
|
|
2822
|
+
if (!source) return;
|
|
2823
|
+
const s = source.settings || {};
|
|
2824
|
+
const p = source.policy || {};
|
|
2825
|
+
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
2826
|
+
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
2827
|
+
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
2828
|
+
if (s.enableHookLogDebug !== void 0)
|
|
2829
|
+
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
2830
|
+
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
2831
|
+
if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
|
|
2832
|
+
if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
|
|
2833
|
+
mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
|
|
2834
|
+
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
2835
|
+
if (s.cloudSyncIntervalHours !== void 0)
|
|
2836
|
+
mergedSettings.cloudSyncIntervalHours = s.cloudSyncIntervalHours;
|
|
2837
|
+
if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
|
|
2838
|
+
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
2839
|
+
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
2840
|
+
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
2841
|
+
if (p.toolInspection)
|
|
2842
|
+
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
2843
|
+
if (p.smartRules) {
|
|
2844
|
+
const defaultBlocks = mergedPolicy.smartRules.filter((r) => r.verdict === "block");
|
|
2845
|
+
const defaultNonBlocks = mergedPolicy.smartRules.filter((r) => r.verdict !== "block");
|
|
2846
|
+
const userRuleNames = new Set(p.smartRules.filter((r) => r.name).map((r) => r.name));
|
|
2847
|
+
const filteredBlocks = defaultBlocks.filter((r) => !r.name || !userRuleNames.has(r.name));
|
|
2848
|
+
const filteredNonBlocks = defaultNonBlocks.filter(
|
|
2849
|
+
(r) => !r.name || !userRuleNames.has(r.name)
|
|
2850
|
+
);
|
|
2851
|
+
mergedPolicy.smartRules = [...filteredBlocks, ...p.smartRules, ...filteredNonBlocks];
|
|
2852
|
+
}
|
|
2853
|
+
if (p.snapshot) {
|
|
2854
|
+
const s2 = p.snapshot;
|
|
2855
|
+
if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
|
|
2856
|
+
if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
|
|
2857
|
+
if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
|
|
2858
|
+
}
|
|
2859
|
+
if (p.dlp) {
|
|
2860
|
+
const d = p.dlp;
|
|
2861
|
+
if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
|
|
2862
|
+
if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
|
|
2863
|
+
}
|
|
2864
|
+
if (p.loopDetection) {
|
|
2865
|
+
const ld = p.loopDetection;
|
|
2866
|
+
if (ld.enabled !== void 0) mergedPolicy.loopDetection.enabled = ld.enabled;
|
|
2867
|
+
if (ld.threshold !== void 0) mergedPolicy.loopDetection.threshold = ld.threshold;
|
|
2868
|
+
if (ld.windowSeconds !== void 0)
|
|
2869
|
+
mergedPolicy.loopDetection.windowSeconds = ld.windowSeconds;
|
|
2870
|
+
}
|
|
2871
|
+
if (p.skillPinning && typeof p.skillPinning === "object") {
|
|
2872
|
+
const sp = p.skillPinning;
|
|
2873
|
+
if (sp.enabled !== void 0) mergedPolicy.skillPinning.enabled = sp.enabled;
|
|
2874
|
+
if (sp.mode !== void 0) mergedPolicy.skillPinning.mode = sp.mode;
|
|
2875
|
+
if (Array.isArray(sp.roots)) {
|
|
2876
|
+
for (const r of sp.roots) {
|
|
2877
|
+
if (typeof r === "string" && r.length > 0) mergedPolicy.skillPinning.roots.push(r);
|
|
2878
|
+
}
|
|
2335
2879
|
}
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2880
|
+
}
|
|
2881
|
+
const envs = source.environments || {};
|
|
2882
|
+
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
2883
|
+
if (envConfig && typeof envConfig === "object") {
|
|
2884
|
+
const ec = envConfig;
|
|
2885
|
+
mergedEnvironments[envName] = {
|
|
2886
|
+
...mergedEnvironments[envName],
|
|
2887
|
+
// Validate field types before merging — do not blindly spread user input
|
|
2888
|
+
...typeof ec.requireApproval === "boolean" ? { requireApproval: ec.requireApproval } : {}
|
|
2342
2889
|
};
|
|
2343
2890
|
}
|
|
2344
2891
|
}
|
|
2345
|
-
|
|
2346
|
-
|
|
2892
|
+
};
|
|
2893
|
+
applyLayer(globalConfig);
|
|
2894
|
+
applyLayer(projectConfig);
|
|
2895
|
+
{
|
|
2896
|
+
const cacheFile = path3.join(os3.homedir(), ".node9", "rules-cache.json");
|
|
2897
|
+
try {
|
|
2898
|
+
const raw = JSON.parse(fs3.readFileSync(cacheFile, "utf-8"));
|
|
2899
|
+
if (Array.isArray(raw.rules) && raw.rules.length > 0) {
|
|
2900
|
+
applyLayer({ policy: { smartRules: raw.rules } });
|
|
2901
|
+
}
|
|
2902
|
+
} catch {
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
const shieldOverrides = readShieldOverrides();
|
|
2906
|
+
for (const shieldName of readActiveShields()) {
|
|
2907
|
+
const shield = getShield(shieldName);
|
|
2908
|
+
if (!shield) continue;
|
|
2909
|
+
const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
2910
|
+
const ruleOverrides = shieldOverrides[shieldName] ?? {};
|
|
2911
|
+
for (const rule of shield.smartRules) {
|
|
2912
|
+
if (!existingRuleNames.has(rule.name)) {
|
|
2913
|
+
const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
|
|
2914
|
+
mergedPolicy.smartRules.push(
|
|
2915
|
+
overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
|
|
2916
|
+
);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
const existingWords = new Set(mergedPolicy.dangerousWords);
|
|
2920
|
+
for (const word of shield.dangerousWords) {
|
|
2921
|
+
if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
2925
|
+
for (const rule of ADVISORY_SMART_RULES) {
|
|
2926
|
+
if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
|
|
2927
|
+
}
|
|
2928
|
+
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
2929
|
+
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
2930
|
+
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
2931
|
+
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
2932
|
+
mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
|
|
2933
|
+
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
2934
|
+
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
2935
|
+
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
2936
|
+
const result = {
|
|
2937
|
+
settings: mergedSettings,
|
|
2938
|
+
policy: mergedPolicy,
|
|
2939
|
+
environments: mergedEnvironments
|
|
2940
|
+
};
|
|
2941
|
+
if (!cwd) cachedConfig = result;
|
|
2942
|
+
return result;
|
|
2943
|
+
}
|
|
2944
|
+
function tryLoadConfig(filePath) {
|
|
2945
|
+
if (!fs3.existsSync(filePath)) return null;
|
|
2946
|
+
let raw;
|
|
2947
|
+
try {
|
|
2948
|
+
raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
2949
|
+
} catch (err) {
|
|
2950
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2951
|
+
process.stderr.write(
|
|
2952
|
+
`
|
|
2953
|
+
\u26A0\uFE0F Node9: Failed to parse ${filePath}
|
|
2954
|
+
${msg}
|
|
2955
|
+
\u2192 Using default config
|
|
2956
|
+
|
|
2957
|
+
`
|
|
2958
|
+
);
|
|
2959
|
+
return null;
|
|
2960
|
+
}
|
|
2961
|
+
const SUPPORTED_VERSION = "1.0";
|
|
2962
|
+
const SUPPORTED_MAJOR = SUPPORTED_VERSION.split(".")[0];
|
|
2963
|
+
const fileVersion = raw?.version;
|
|
2964
|
+
if (fileVersion !== void 0) {
|
|
2965
|
+
const vStr = String(fileVersion);
|
|
2966
|
+
const fileMajor = vStr.split(".")[0];
|
|
2967
|
+
if (fileMajor !== SUPPORTED_MAJOR) {
|
|
2968
|
+
process.stderr.write(
|
|
2969
|
+
`
|
|
2970
|
+
\u274C Node9: Config at ${filePath} has version "${vStr}" \u2014 major version is incompatible with this release (expected "${SUPPORTED_VERSION}"). Config will not be loaded.
|
|
2971
|
+
|
|
2972
|
+
`
|
|
2973
|
+
);
|
|
2974
|
+
return null;
|
|
2975
|
+
} else if (vStr !== SUPPORTED_VERSION) {
|
|
2976
|
+
process.stderr.write(
|
|
2977
|
+
`
|
|
2978
|
+
\u26A0\uFE0F Node9: Config at ${filePath} declares version "${vStr}" \u2014 expected "${SUPPORTED_VERSION}". Continuing with best-effort parsing.
|
|
2979
|
+
|
|
2980
|
+
`
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
const { sanitized, error } = sanitizeConfig(raw);
|
|
2985
|
+
if (error) {
|
|
2986
|
+
process.stderr.write(
|
|
2987
|
+
`
|
|
2988
|
+
\u26A0\uFE0F Node9: Invalid config at ${filePath}:
|
|
2989
|
+
${error.replace("Invalid config:\n", "")}
|
|
2990
|
+
\u2192 Invalid fields ignored, using defaults for those keys
|
|
2991
|
+
|
|
2992
|
+
`
|
|
2993
|
+
);
|
|
2994
|
+
}
|
|
2995
|
+
return sanitized;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
// src/policy/index.ts
|
|
2999
|
+
import pm2 from "picomatch";
|
|
3000
|
+
|
|
3001
|
+
// src/dlp.ts
|
|
3002
|
+
import fs4 from "fs";
|
|
3003
|
+
import path4 from "path";
|
|
3004
|
+
function scanFilePath(filePath, cwd = process.cwd()) {
|
|
3005
|
+
if (!filePath) return null;
|
|
3006
|
+
let resolved;
|
|
3007
|
+
try {
|
|
3008
|
+
const absolute = path4.resolve(cwd, filePath);
|
|
3009
|
+
resolved = fs4.realpathSync.native(absolute);
|
|
3010
|
+
} catch (err) {
|
|
3011
|
+
const code = err.code;
|
|
3012
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
3013
|
+
resolved = path4.resolve(cwd, filePath);
|
|
3014
|
+
} else {
|
|
3015
|
+
return sensitivePathMatch(filePath);
|
|
2347
3016
|
}
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
3017
|
+
}
|
|
3018
|
+
return matchSensitivePath(resolved, filePath);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// src/utils/provenance.ts
|
|
3022
|
+
import fs5 from "fs";
|
|
3023
|
+
import path5 from "path";
|
|
3024
|
+
import os4 from "os";
|
|
3025
|
+
var SYSTEM_PREFIXES = ["/usr/bin", "/usr/sbin", "/bin", "/sbin"];
|
|
3026
|
+
var MANAGED_PREFIXES = ["/usr/local/bin", "/opt/homebrew", "/home/linuxbrew", "/nix/store"];
|
|
3027
|
+
var USER_PREFIXES = [
|
|
3028
|
+
path5.join(os4.homedir(), "bin"),
|
|
3029
|
+
path5.join(os4.homedir(), ".local", "bin"),
|
|
3030
|
+
path5.join(os4.homedir(), ".cargo", "bin"),
|
|
3031
|
+
path5.join(os4.homedir(), ".npm-global", "bin"),
|
|
3032
|
+
path5.join(os4.homedir(), ".volta", "bin")
|
|
3033
|
+
];
|
|
3034
|
+
var SUSPECT_PREFIXES = ["/tmp", "/var/tmp", "/dev/shm"];
|
|
3035
|
+
function findInPath(cmd) {
|
|
3036
|
+
if (path5.posix.isAbsolute(cmd)) return cmd;
|
|
3037
|
+
const pathEnv = process.env.PATH ?? "";
|
|
3038
|
+
for (const dir of pathEnv.split(path5.delimiter)) {
|
|
3039
|
+
if (!dir) continue;
|
|
3040
|
+
const full = path5.join(dir, cmd);
|
|
3041
|
+
try {
|
|
3042
|
+
fs5.accessSync(full, fs5.constants.X_OK);
|
|
3043
|
+
return full;
|
|
3044
|
+
} catch {
|
|
2354
3045
|
}
|
|
2355
3046
|
}
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
3047
|
+
return null;
|
|
3048
|
+
}
|
|
3049
|
+
function _classifyPath(resolved, cwd) {
|
|
3050
|
+
if (cwd && resolved.startsWith(cwd + "/")) {
|
|
3051
|
+
return { trustLevel: "user", reason: "binary in project directory" };
|
|
3052
|
+
}
|
|
3053
|
+
const osTmp = os4.tmpdir();
|
|
3054
|
+
const allSuspect = osTmp ? [...SUSPECT_PREFIXES, osTmp] : SUSPECT_PREFIXES;
|
|
3055
|
+
if (allSuspect.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
|
|
3056
|
+
return { trustLevel: "suspect", reason: `binary in temp directory: ${resolved}` };
|
|
3057
|
+
}
|
|
3058
|
+
if (SYSTEM_PREFIXES.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
|
|
3059
|
+
return { trustLevel: "system", reason: "" };
|
|
3060
|
+
}
|
|
3061
|
+
if (MANAGED_PREFIXES.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
|
|
3062
|
+
return { trustLevel: "managed", reason: "" };
|
|
3063
|
+
}
|
|
3064
|
+
if (USER_PREFIXES.some((p) => resolved === p || resolved.startsWith(p + "/"))) {
|
|
3065
|
+
return { trustLevel: "user", reason: "" };
|
|
3066
|
+
}
|
|
3067
|
+
return { trustLevel: "unknown", reason: "binary in unrecognized location" };
|
|
3068
|
+
}
|
|
3069
|
+
function checkProvenance(cmd, cwd) {
|
|
3070
|
+
const bare = cmd.startsWith("./") ? cmd.slice(2) : cmd;
|
|
3071
|
+
if (path5.posix.isAbsolute(bare)) {
|
|
3072
|
+
const early = _classifyPath(bare, cwd);
|
|
3073
|
+
if (early.trustLevel === "suspect") {
|
|
3074
|
+
return { resolvedPath: bare, ...early };
|
|
2365
3075
|
}
|
|
2366
|
-
return { decision: "allow" };
|
|
2367
3076
|
}
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
3077
|
+
let resolved;
|
|
3078
|
+
try {
|
|
3079
|
+
const found = findInPath(bare);
|
|
3080
|
+
if (!found) {
|
|
3081
|
+
return {
|
|
3082
|
+
resolvedPath: cmd,
|
|
3083
|
+
trustLevel: "unknown",
|
|
3084
|
+
reason: "binary not found in PATH"
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
resolved = fs5.realpathSync(found);
|
|
3088
|
+
} catch {
|
|
3089
|
+
return {
|
|
3090
|
+
resolvedPath: cmd,
|
|
3091
|
+
trustLevel: "unknown",
|
|
3092
|
+
reason: "binary not found in PATH"
|
|
3093
|
+
};
|
|
2371
3094
|
}
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
(
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
return false;
|
|
2381
|
-
}
|
|
2382
|
-
})();
|
|
2383
|
-
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
2384
|
-
return hit;
|
|
2385
|
-
})
|
|
2386
|
-
);
|
|
2387
|
-
if (isDangerous) {
|
|
2388
|
-
let matchedField;
|
|
2389
|
-
if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
|
|
2390
|
-
const obj = args;
|
|
2391
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
2392
|
-
if (typeof value === "string") {
|
|
2393
|
-
try {
|
|
2394
|
-
if (new RegExp(
|
|
2395
|
-
`\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
2396
|
-
"i"
|
|
2397
|
-
).test(value)) {
|
|
2398
|
-
matchedField = key;
|
|
2399
|
-
break;
|
|
2400
|
-
}
|
|
2401
|
-
} catch {
|
|
2402
|
-
}
|
|
2403
|
-
}
|
|
2404
|
-
}
|
|
3095
|
+
try {
|
|
3096
|
+
const stat = fs5.statSync(resolved);
|
|
3097
|
+
if (stat.mode & 2) {
|
|
3098
|
+
return {
|
|
3099
|
+
resolvedPath: resolved,
|
|
3100
|
+
trustLevel: "suspect",
|
|
3101
|
+
reason: "binary is world-writable"
|
|
3102
|
+
};
|
|
2405
3103
|
}
|
|
3104
|
+
} catch {
|
|
2406
3105
|
return {
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
matchedField,
|
|
2411
|
-
ruleDescription: `This command contains a flagged keyword ("${matchedDangerousWord}") from your node9 config. Review it before allowing.`,
|
|
2412
|
-
tier: 6
|
|
3106
|
+
resolvedPath: resolved,
|
|
3107
|
+
trustLevel: "unknown",
|
|
3108
|
+
reason: "could not stat binary"
|
|
2413
3109
|
};
|
|
2414
3110
|
}
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
3111
|
+
const classify = _classifyPath(resolved, cwd);
|
|
3112
|
+
return { resolvedPath: resolved, ...classify };
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
// src/auth/trusted-hosts.ts
|
|
3116
|
+
import fs6 from "fs";
|
|
3117
|
+
import path6 from "path";
|
|
3118
|
+
import os5 from "os";
|
|
3119
|
+
function getTrustedHostsPath() {
|
|
3120
|
+
return path6.join(os5.homedir(), ".node9", "trusted-hosts.json");
|
|
3121
|
+
}
|
|
3122
|
+
function readTrustedHosts() {
|
|
3123
|
+
try {
|
|
3124
|
+
const raw = fs6.readFileSync(getTrustedHostsPath(), "utf8");
|
|
3125
|
+
const parsed = JSON.parse(raw);
|
|
3126
|
+
return Array.isArray(parsed.hosts) ? parsed.hosts : [];
|
|
3127
|
+
} catch {
|
|
3128
|
+
return [];
|
|
2419
3129
|
}
|
|
2420
|
-
return { decision: "allow" };
|
|
2421
3130
|
}
|
|
2422
|
-
|
|
3131
|
+
var _cache = null;
|
|
3132
|
+
var CACHE_TTL_MS = 5e3;
|
|
3133
|
+
function getFileMtime() {
|
|
3134
|
+
try {
|
|
3135
|
+
return fs6.statSync(getTrustedHostsPath()).mtimeMs;
|
|
3136
|
+
} catch {
|
|
3137
|
+
return 0;
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
function getCachedHosts() {
|
|
3141
|
+
const now = Date.now();
|
|
3142
|
+
if (_cache && now < _cache.expiry) {
|
|
3143
|
+
const mtime = getFileMtime();
|
|
3144
|
+
if (mtime === _cache.mtime) return _cache.hosts;
|
|
3145
|
+
}
|
|
3146
|
+
const hosts = readTrustedHosts();
|
|
3147
|
+
_cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
|
|
3148
|
+
return hosts;
|
|
3149
|
+
}
|
|
3150
|
+
function normalizeHost(raw) {
|
|
3151
|
+
return raw.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^[^@]+@/, "").replace(/:\d+$/, "");
|
|
3152
|
+
}
|
|
3153
|
+
function isTrustedHost(host) {
|
|
3154
|
+
const normalized = normalizeHost(host);
|
|
3155
|
+
return getCachedHosts().some((entry) => {
|
|
3156
|
+
const entryHost = entry.host.toLowerCase();
|
|
3157
|
+
if (entryHost.startsWith("*.")) {
|
|
3158
|
+
const domain = entryHost.slice(2);
|
|
3159
|
+
return normalized.endsWith("." + domain);
|
|
3160
|
+
}
|
|
3161
|
+
return normalized === entryHost;
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// src/policy/index.ts
|
|
3166
|
+
async function evaluatePolicy2(toolName, args, agent, cwd) {
|
|
2423
3167
|
const config = getConfig();
|
|
2424
|
-
|
|
3168
|
+
const activeEnvironment = getActiveEnvironment(config) ?? void 0;
|
|
3169
|
+
return evaluatePolicy(
|
|
3170
|
+
config,
|
|
3171
|
+
toolName,
|
|
3172
|
+
args,
|
|
3173
|
+
{ agent, cwd, activeEnvironment },
|
|
3174
|
+
{ checkProvenance, isTrustedHost }
|
|
3175
|
+
);
|
|
3176
|
+
}
|
|
3177
|
+
function isIgnoredTool2(toolName) {
|
|
3178
|
+
return isIgnoredTool(toolName, getConfig());
|
|
2425
3179
|
}
|
|
2426
3180
|
|
|
2427
3181
|
// src/auth/state.ts
|
|
2428
3182
|
import fs7 from "fs";
|
|
2429
|
-
import
|
|
3183
|
+
import path7 from "path";
|
|
2430
3184
|
import os6 from "os";
|
|
2431
|
-
var PAUSED_FILE =
|
|
2432
|
-
var TRUST_FILE =
|
|
3185
|
+
var PAUSED_FILE = path7.join(os6.homedir(), ".node9", "PAUSED");
|
|
3186
|
+
var TRUST_FILE = path7.join(os6.homedir(), ".node9", "trust.json");
|
|
2433
3187
|
function extractCommandPattern(toolName, args) {
|
|
2434
3188
|
const lower = toolName.toLowerCase();
|
|
2435
3189
|
if (lower !== "bash" && lower !== "execute_bash" && lower !== "shell") return void 0;
|
|
@@ -2456,7 +3210,7 @@ function checkPause() {
|
|
|
2456
3210
|
}
|
|
2457
3211
|
}
|
|
2458
3212
|
function atomicWriteSync(filePath, data, options) {
|
|
2459
|
-
const dir =
|
|
3213
|
+
const dir = path7.dirname(filePath);
|
|
2460
3214
|
if (!fs7.existsSync(dir)) fs7.mkdirSync(dir, { recursive: true });
|
|
2461
3215
|
const tmpPath = `${filePath}.${os6.hostname()}.${process.pid}.tmp`;
|
|
2462
3216
|
fs7.writeFileSync(tmpPath, data, options);
|
|
@@ -2511,7 +3265,7 @@ function writeTrustSession(toolName, durationMs, args) {
|
|
|
2511
3265
|
}
|
|
2512
3266
|
function getPersistentDecision(toolName) {
|
|
2513
3267
|
try {
|
|
2514
|
-
const file =
|
|
3268
|
+
const file = path7.join(os6.homedir(), ".node9", "decisions.json");
|
|
2515
3269
|
if (!fs7.existsSync(file)) return null;
|
|
2516
3270
|
const decisions = JSON.parse(fs7.readFileSync(file, "utf-8"));
|
|
2517
3271
|
const d = decisions[toolName];
|
|
@@ -2524,10 +3278,10 @@ function getPersistentDecision(toolName) {
|
|
|
2524
3278
|
// src/auth/daemon.ts
|
|
2525
3279
|
import fs8 from "fs";
|
|
2526
3280
|
import net from "net";
|
|
2527
|
-
import
|
|
3281
|
+
import path8 from "path";
|
|
2528
3282
|
import os7 from "os";
|
|
2529
3283
|
import { spawnSync } from "child_process";
|
|
2530
|
-
var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" :
|
|
3284
|
+
var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path8.join(os7.tmpdir(), "node9-activity.sock");
|
|
2531
3285
|
function notifyActivitySocket(data) {
|
|
2532
3286
|
return new Promise((resolve) => {
|
|
2533
3287
|
try {
|
|
@@ -2560,7 +3314,7 @@ var DAEMON_PORT = 7391;
|
|
|
2560
3314
|
var DAEMON_HOST = "127.0.0.1";
|
|
2561
3315
|
function getInternalToken() {
|
|
2562
3316
|
try {
|
|
2563
|
-
const pidFile =
|
|
3317
|
+
const pidFile = path8.join(os7.homedir(), ".node9", "daemon.pid");
|
|
2564
3318
|
if (!fs8.existsSync(pidFile)) return null;
|
|
2565
3319
|
const data = JSON.parse(fs8.readFileSync(pidFile, "utf-8"));
|
|
2566
3320
|
process.kill(data.pid, 0);
|
|
@@ -2570,7 +3324,7 @@ function getInternalToken() {
|
|
|
2570
3324
|
}
|
|
2571
3325
|
}
|
|
2572
3326
|
function isDaemonRunning() {
|
|
2573
|
-
const pidFile =
|
|
3327
|
+
const pidFile = path8.join(os7.homedir(), ".node9", "daemon.pid");
|
|
2574
3328
|
if (fs8.existsSync(pidFile)) {
|
|
2575
3329
|
let pid;
|
|
2576
3330
|
let port;
|
|
@@ -2743,10 +3497,10 @@ import { randomUUID } from "crypto";
|
|
|
2743
3497
|
|
|
2744
3498
|
// src/ui/native.ts
|
|
2745
3499
|
import { spawn } from "child_process";
|
|
2746
|
-
import
|
|
3500
|
+
import path10 from "path";
|
|
2747
3501
|
|
|
2748
3502
|
// src/context-sniper.ts
|
|
2749
|
-
import
|
|
3503
|
+
import path9 from "path";
|
|
2750
3504
|
function smartTruncate(str, maxLen = 500) {
|
|
2751
3505
|
if (str.length <= maxLen) return str;
|
|
2752
3506
|
const edge = Math.floor(maxLen / 2) - 3;
|
|
@@ -2814,7 +3568,7 @@ function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWo
|
|
|
2814
3568
|
intent = "EDIT";
|
|
2815
3569
|
if (obj.file_path) {
|
|
2816
3570
|
editFilePath = String(obj.file_path);
|
|
2817
|
-
editFileName =
|
|
3571
|
+
editFileName = path9.basename(editFilePath);
|
|
2818
3572
|
}
|
|
2819
3573
|
const result = extractContext(String(obj.new_string), matchedWord);
|
|
2820
3574
|
contextSnippet = result.snippet;
|
|
@@ -2869,7 +3623,7 @@ function formatArgs(args, matchedField, matchedWord) {
|
|
|
2869
3623
|
if (typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2870
3624
|
const obj = parsed;
|
|
2871
3625
|
if (obj.old_string !== void 0 && obj.new_string !== void 0) {
|
|
2872
|
-
const file = obj.file_path ?
|
|
3626
|
+
const file = obj.file_path ? path10.basename(String(obj.file_path)) : "file";
|
|
2873
3627
|
const oldPreview = smartTruncate(String(obj.old_string), 120);
|
|
2874
3628
|
const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
|
|
2875
3629
|
return {
|
|
@@ -3071,15 +3825,62 @@ init_audit();
|
|
|
3071
3825
|
init_audit();
|
|
3072
3826
|
import fs9 from "fs";
|
|
3073
3827
|
import os8 from "os";
|
|
3074
|
-
import
|
|
3075
|
-
|
|
3076
|
-
|
|
3828
|
+
import path11 from "path";
|
|
3829
|
+
var DLP_SAMPLE_MAX_LEN = 200;
|
|
3830
|
+
var DLP_PATTERN_MAX_LEN = 100;
|
|
3831
|
+
var KNOWN_CHECKED_BY = /* @__PURE__ */ new Set([
|
|
3832
|
+
"dlp-block",
|
|
3833
|
+
"observe-mode-dlp-would-block",
|
|
3834
|
+
"dlp-review-flagged",
|
|
3835
|
+
"loop-detected",
|
|
3836
|
+
"audit-mode",
|
|
3837
|
+
"local-policy",
|
|
3838
|
+
"smart-rule-block",
|
|
3839
|
+
"persistent",
|
|
3840
|
+
"trust",
|
|
3841
|
+
"observe-mode",
|
|
3842
|
+
"observe-mode-would-block"
|
|
3843
|
+
]);
|
|
3844
|
+
function validateApiUrl(raw) {
|
|
3845
|
+
let u;
|
|
3846
|
+
try {
|
|
3847
|
+
u = new URL(raw);
|
|
3848
|
+
} catch {
|
|
3849
|
+
return null;
|
|
3850
|
+
}
|
|
3851
|
+
if (u.username || u.password) return null;
|
|
3852
|
+
if (u.protocol === "https:") return u;
|
|
3853
|
+
if (u.protocol === "http:") {
|
|
3854
|
+
const h = u.hostname;
|
|
3855
|
+
if (h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]") return u;
|
|
3856
|
+
}
|
|
3857
|
+
return null;
|
|
3858
|
+
}
|
|
3859
|
+
function auditLocalAllow(toolName, args, checkedBy, creds, meta, dlpInfo, containsSensitiveArgs = false) {
|
|
3860
|
+
const validated = validateApiUrl(creds.apiUrl);
|
|
3861
|
+
if (!validated) {
|
|
3862
|
+
try {
|
|
3863
|
+
fs9.appendFileSync(
|
|
3864
|
+
HOOK_DEBUG_LOG,
|
|
3865
|
+
`[audit] refused to send: invalid apiUrl scheme/host (got "${String(creds.apiUrl).slice(0, 200)}")
|
|
3866
|
+
`
|
|
3867
|
+
);
|
|
3868
|
+
} catch {
|
|
3869
|
+
}
|
|
3870
|
+
return Promise.resolve();
|
|
3871
|
+
}
|
|
3872
|
+
const safeArgs = containsSensitiveArgs ? { tool: toolName, redacted: true } : args;
|
|
3873
|
+
const dlpSample = dlpInfo && typeof dlpInfo.redactedSample === "string" ? dlpInfo.redactedSample.slice(0, DLP_SAMPLE_MAX_LEN) : void 0;
|
|
3874
|
+
const dlpPattern = dlpInfo && typeof dlpInfo.pattern === "string" ? dlpInfo.pattern.slice(0, DLP_PATTERN_MAX_LEN) : void 0;
|
|
3875
|
+
const safeCheckedBy = KNOWN_CHECKED_BY.has(checkedBy) ? checkedBy : "unknown";
|
|
3876
|
+
return fetch(`${validated.toString().replace(/\/$/, "")}/audit`, {
|
|
3077
3877
|
method: "POST",
|
|
3078
3878
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
3079
3879
|
body: JSON.stringify({
|
|
3080
3880
|
toolName,
|
|
3081
|
-
args,
|
|
3082
|
-
checkedBy,
|
|
3881
|
+
args: safeArgs,
|
|
3882
|
+
checkedBy: safeCheckedBy,
|
|
3883
|
+
...dlpInfo && { dlpPattern, dlpSample },
|
|
3083
3884
|
context: {
|
|
3084
3885
|
agent: meta?.agent,
|
|
3085
3886
|
mcpServer: meta?.mcpServer,
|
|
@@ -3100,7 +3901,7 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
|
|
|
3100
3901
|
let ciContext;
|
|
3101
3902
|
if (process.env.CI) {
|
|
3102
3903
|
try {
|
|
3103
|
-
const ciContextPath =
|
|
3904
|
+
const ciContextPath = path11.join(os8.homedir(), ".node9", "ci-context.json");
|
|
3104
3905
|
const stats = fs9.statSync(ciContextPath);
|
|
3105
3906
|
if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
|
|
3106
3907
|
const raw = fs9.readFileSync(ciContextPath, "utf8");
|
|
@@ -3216,16 +4017,10 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
|
3216
4017
|
|
|
3217
4018
|
// src/loop-detector.ts
|
|
3218
4019
|
import fs10 from "fs";
|
|
3219
|
-
import
|
|
4020
|
+
import path12 from "path";
|
|
3220
4021
|
import os9 from "os";
|
|
3221
|
-
import crypto from "crypto";
|
|
3222
4022
|
function loopStateFile() {
|
|
3223
|
-
return
|
|
3224
|
-
}
|
|
3225
|
-
var MAX_RECORDS = 500;
|
|
3226
|
-
function computeArgsHash(args) {
|
|
3227
|
-
const str = JSON.stringify(args ?? "");
|
|
3228
|
-
return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
4023
|
+
return path12.join(os9.homedir(), ".node9", "loop-state.json");
|
|
3229
4024
|
}
|
|
3230
4025
|
function readState() {
|
|
3231
4026
|
try {
|
|
@@ -3239,7 +4034,7 @@ function readState() {
|
|
|
3239
4034
|
}
|
|
3240
4035
|
}
|
|
3241
4036
|
function writeState(records) {
|
|
3242
|
-
const dir =
|
|
4037
|
+
const dir = path12.dirname(loopStateFile());
|
|
3243
4038
|
if (!fs10.existsSync(dir)) fs10.mkdirSync(dir, { recursive: true });
|
|
3244
4039
|
const tmpPath = `${loopStateFile()}.${os9.hostname()}.${process.pid}.tmp`;
|
|
3245
4040
|
fs10.writeFileSync(tmpPath, JSON.stringify(records));
|
|
@@ -3247,14 +4042,9 @@ function writeState(records) {
|
|
|
3247
4042
|
}
|
|
3248
4043
|
function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
|
|
3249
4044
|
try {
|
|
3250
|
-
const
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
const records = readState().filter((r) => r.ts >= cutoff);
|
|
3254
|
-
records.push({ t: tool, h: hash, ts: now });
|
|
3255
|
-
const count = records.filter((r) => r.t === tool && r.h === hash).length;
|
|
3256
|
-
writeState(records.slice(-MAX_RECORDS));
|
|
3257
|
-
return { looping: count >= threshold, count };
|
|
4045
|
+
const result = evaluateLoopWindow(readState(), tool, args, threshold, windowMs, Date.now());
|
|
4046
|
+
writeState(result.nextRecords);
|
|
4047
|
+
return { looping: result.looping, count: result.count };
|
|
3258
4048
|
} catch {
|
|
3259
4049
|
return { looping: false, count: 0 };
|
|
3260
4050
|
}
|
|
@@ -3387,7 +4177,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3387
4177
|
}
|
|
3388
4178
|
}
|
|
3389
4179
|
}
|
|
3390
|
-
if (config.policy.dlp.enabled && (!
|
|
4180
|
+
if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
|
|
3391
4181
|
const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
3392
4182
|
const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
|
|
3393
4183
|
const dlpMatch = (filePath ? scanFilePath(filePath) : null) ?? scanArgs(args);
|
|
@@ -3403,6 +4193,16 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3403
4193
|
meta,
|
|
3404
4194
|
true
|
|
3405
4195
|
);
|
|
4196
|
+
if (approvers.cloud && creds?.apiKey)
|
|
4197
|
+
auditLocalAllow(
|
|
4198
|
+
toolName,
|
|
4199
|
+
args,
|
|
4200
|
+
isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
|
|
4201
|
+
creds,
|
|
4202
|
+
meta,
|
|
4203
|
+
{ pattern: dlpMatch.patternName, redactedSample: dlpMatch.redactedSample },
|
|
4204
|
+
true
|
|
4205
|
+
);
|
|
3406
4206
|
if (isWriteTool(toolName) && filePath) {
|
|
3407
4207
|
await notifyTaint(filePath, `DLP:${dlpMatch.patternName}`);
|
|
3408
4208
|
}
|
|
@@ -3427,8 +4227,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3427
4227
|
}
|
|
3428
4228
|
}
|
|
3429
4229
|
if (isObserveMode) {
|
|
3430
|
-
if (!
|
|
3431
|
-
const policyResult = await
|
|
4230
|
+
if (!isIgnoredTool2(toolName)) {
|
|
4231
|
+
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
3432
4232
|
const wouldBlock = policyResult.decision === "block";
|
|
3433
4233
|
if (!isManual)
|
|
3434
4234
|
appendLocalAudit(
|
|
@@ -3452,8 +4252,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3452
4252
|
return { approved: true, checkedBy: "audit" };
|
|
3453
4253
|
}
|
|
3454
4254
|
if (config.settings.mode === "audit") {
|
|
3455
|
-
if (!
|
|
3456
|
-
const policyResult = await
|
|
4255
|
+
if (!isIgnoredTool2(toolName)) {
|
|
4256
|
+
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
3457
4257
|
if (policyResult.decision === "review") {
|
|
3458
4258
|
appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
|
|
3459
4259
|
if (approvers.cloud && creds?.apiKey) {
|
|
@@ -3463,7 +4263,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3463
4263
|
}
|
|
3464
4264
|
return { approved: true, checkedBy: "audit" };
|
|
3465
4265
|
}
|
|
3466
|
-
if (!taintWarning && !
|
|
4266
|
+
if (!taintWarning && !isIgnoredTool2(toolName)) {
|
|
3467
4267
|
const ld = config.policy.loopDetection;
|
|
3468
4268
|
if (ld.enabled) {
|
|
3469
4269
|
const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
|
|
@@ -3471,6 +4271,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3471
4271
|
const reason = `It looks like you've called "${toolName}" ${loopResult.count} times with identical arguments in the last ${ld.windowSeconds}s. Are you stuck? Step back and reconsider your approach \u2014 what are you actually trying to accomplish, and is there a different way to get there?`;
|
|
3472
4272
|
if (!isManual)
|
|
3473
4273
|
appendLocalAudit(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
|
|
4274
|
+
if (approvers.cloud && creds?.apiKey)
|
|
4275
|
+
auditLocalAllow(toolName, args, "loop-detected", creds, meta, void 0, true);
|
|
3474
4276
|
return {
|
|
3475
4277
|
approved: false,
|
|
3476
4278
|
reason,
|
|
@@ -3479,7 +4281,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
3479
4281
|
};
|
|
3480
4282
|
}
|
|
3481
4283
|
}
|
|
3482
|
-
const policyResult = await
|
|
4284
|
+
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent);
|
|
3483
4285
|
if (policyResult.decision === "allow") {
|
|
3484
4286
|
if (approvers.cloud && creds?.apiKey)
|
|
3485
4287
|
await auditLocalAllow(toolName, args, "local-policy", creds, meta);
|