vibesafu 0.1.25 → 0.1.27
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/dist/index.js +304 -199
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
|
|
|
7
7
|
import { dirname, join as join3 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/cli/install.ts
|
|
10
|
-
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
10
|
+
import { readFile, writeFile, mkdir, chmod } from "fs/promises";
|
|
11
11
|
import { homedir } from "os";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
var CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
@@ -24,7 +24,12 @@ async function readClaudeSettings() {
|
|
|
24
24
|
try {
|
|
25
25
|
const content = await readFile(CLAUDE_SETTINGS_PATH, "utf-8");
|
|
26
26
|
return JSON.parse(content);
|
|
27
|
-
} catch {
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
32
|
+
console.error(`Warning: Failed to read Claude settings (${CLAUDE_SETTINGS_PATH}): ${msg}. Starting fresh.`);
|
|
28
33
|
return {};
|
|
29
34
|
}
|
|
30
35
|
}
|
|
@@ -32,6 +37,7 @@ async function writeClaudeSettings(settings) {
|
|
|
32
37
|
const dir = join(homedir(), ".claude");
|
|
33
38
|
await mkdir(dir, { recursive: true });
|
|
34
39
|
await writeFile(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
40
|
+
await chmod(CLAUDE_SETTINGS_PATH, 384);
|
|
35
41
|
}
|
|
36
42
|
function isHookInstalled(settings) {
|
|
37
43
|
const hooks = settings.hooks?.PermissionRequest ?? [];
|
|
@@ -84,7 +90,7 @@ async function uninstall() {
|
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
// src/cli/config.ts
|
|
87
|
-
import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, chmod } from "fs/promises";
|
|
93
|
+
import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, chmod as chmod2 } from "fs/promises";
|
|
88
94
|
import { homedir as homedir2 } from "os";
|
|
89
95
|
import { join as join2 } from "path";
|
|
90
96
|
import { createInterface } from "readline";
|
|
@@ -103,11 +109,7 @@ var DEFAULT_CONFIG = {
|
|
|
103
109
|
block: [],
|
|
104
110
|
allow: []
|
|
105
111
|
},
|
|
106
|
-
allowedMCPTools: []
|
|
107
|
-
logging: {
|
|
108
|
-
enabled: true,
|
|
109
|
-
path: join2(CONFIG_DIR, "logs")
|
|
110
|
-
}
|
|
112
|
+
allowedMCPTools: []
|
|
111
113
|
};
|
|
112
114
|
function mergeConfig(defaults, user) {
|
|
113
115
|
return {
|
|
@@ -115,32 +117,37 @@ function mergeConfig(defaults, user) {
|
|
|
115
117
|
models: { ...defaults.models, ...user.models },
|
|
116
118
|
trustedDomains: user.trustedDomains ?? defaults.trustedDomains,
|
|
117
119
|
customPatterns: { ...defaults.customPatterns, ...user.customPatterns },
|
|
118
|
-
allowedMCPTools: user.allowedMCPTools ?? defaults.allowedMCPTools
|
|
119
|
-
logging: { ...defaults.logging, ...user.logging }
|
|
120
|
+
allowedMCPTools: user.allowedMCPTools ?? defaults.allowedMCPTools
|
|
120
121
|
};
|
|
121
122
|
}
|
|
122
123
|
async function readConfig() {
|
|
123
124
|
try {
|
|
124
125
|
const content = await readFile2(CONFIG_PATH, "utf-8");
|
|
125
126
|
return mergeConfig(DEFAULT_CONFIG, JSON.parse(content));
|
|
126
|
-
} catch {
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
129
|
+
return DEFAULT_CONFIG;
|
|
130
|
+
}
|
|
131
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
132
|
+
process.stderr.write(`[vibesafu] Warning: Failed to read config (${CONFIG_PATH}): ${msg}. Using defaults.
|
|
133
|
+
`);
|
|
127
134
|
return DEFAULT_CONFIG;
|
|
128
135
|
}
|
|
129
136
|
}
|
|
130
137
|
async function writeConfig(config2) {
|
|
131
138
|
await mkdir2(CONFIG_DIR, { recursive: true });
|
|
132
139
|
await writeFile2(CONFIG_PATH, JSON.stringify(config2, null, 2));
|
|
133
|
-
await
|
|
140
|
+
await chmod2(CONFIG_PATH, 384);
|
|
134
141
|
}
|
|
135
142
|
function prompt(question) {
|
|
136
143
|
const rl = createInterface({
|
|
137
144
|
input: process.stdin,
|
|
138
145
|
output: process.stdout
|
|
139
146
|
});
|
|
140
|
-
return new Promise((
|
|
147
|
+
return new Promise((resolve2) => {
|
|
141
148
|
rl.question(question, (answer) => {
|
|
142
149
|
rl.close();
|
|
143
|
-
|
|
150
|
+
resolve2(answer);
|
|
144
151
|
});
|
|
145
152
|
});
|
|
146
153
|
}
|
|
@@ -166,16 +173,6 @@ async function config() {
|
|
|
166
173
|
console.log("Configuration saved!");
|
|
167
174
|
console.log(`Config file: ${CONFIG_PATH}`);
|
|
168
175
|
}
|
|
169
|
-
async function getApiKey() {
|
|
170
|
-
if (process.env.ANTHROPIC_API_KEY) {
|
|
171
|
-
return process.env.ANTHROPIC_API_KEY;
|
|
172
|
-
}
|
|
173
|
-
const cfg = await readConfig();
|
|
174
|
-
if (cfg.anthropic.apiKey) {
|
|
175
|
-
return cfg.anthropic.apiKey;
|
|
176
|
-
}
|
|
177
|
-
return void 0;
|
|
178
|
-
}
|
|
179
176
|
|
|
180
177
|
// src/hook.ts
|
|
181
178
|
import Anthropic from "@anthropic-ai/sdk";
|
|
@@ -779,6 +776,13 @@ var INSTANT_BLOCK_PATTERNS = [
|
|
|
779
776
|
...DESTRUCTIVE_PATTERNS,
|
|
780
777
|
...SELF_PROTECTION_PATTERNS
|
|
781
778
|
];
|
|
779
|
+
for (const p of INSTANT_BLOCK_PATTERNS) {
|
|
780
|
+
if (p.pattern.global) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
`Security pattern "${p.name}" must not use the global (g) flag. The g flag makes RegExp.test() stateful, causing intermittent bypasses. Remove the g flag from the pattern.`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
782
786
|
var CHECKPOINT_PATTERNS = [
|
|
783
787
|
// Script execution
|
|
784
788
|
{ pattern: /curl\s+.*\|\s*(ba)?sh/i, type: "script_execution", description: "curl piped to shell" },
|
|
@@ -822,12 +826,18 @@ var CHECKPOINT_PATTERNS = [
|
|
|
822
826
|
{ pattern: /\.ssh/i, type: "file_sensitive", description: "SSH directory access" },
|
|
823
827
|
{ pattern: /\.aws/i, type: "file_sensitive", description: "AWS credentials access" },
|
|
824
828
|
{ pattern: /credentials/i, type: "file_sensitive", description: "Credentials file access" },
|
|
825
|
-
{ pattern: /CLAUDE\.md/i, type: "file_sensitive", description: "CLAUDE.md modification" },
|
|
826
829
|
// Sensitive file copy/move (indirect path bypass)
|
|
827
830
|
{ pattern: /(cp|mv)\s+.*\.ssh\//i, type: "file_sensitive", description: "Copying/moving SSH files" },
|
|
828
831
|
{ pattern: /(cp|mv)\s+.*\.aws\//i, type: "file_sensitive", description: "Copying/moving AWS credentials" },
|
|
829
832
|
{ pattern: /(cp|mv)\s+.*\.env(\s|$)/i, type: "file_sensitive", description: "Copying/moving .env file" }
|
|
830
833
|
];
|
|
834
|
+
for (const p of CHECKPOINT_PATTERNS) {
|
|
835
|
+
if (p.pattern.global) {
|
|
836
|
+
throw new Error(
|
|
837
|
+
`Checkpoint pattern "${p.description}" must not use the global (g) flag. The g flag makes RegExp.test() stateful, causing intermittent bypasses. Remove the g flag from the pattern.`
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
831
841
|
|
|
832
842
|
// src/guard/instant-block.ts
|
|
833
843
|
function checkHighRiskPatterns(command) {
|
|
@@ -1034,7 +1044,10 @@ function isTrustedUrl(url) {
|
|
|
1034
1044
|
function extractUrls(command) {
|
|
1035
1045
|
const urlPattern = /https?:\/\/[^\s"'<>]+/gi;
|
|
1036
1046
|
const matches = command.match(urlPattern);
|
|
1037
|
-
|
|
1047
|
+
if (!matches) return [];
|
|
1048
|
+
return matches.map((url) => {
|
|
1049
|
+
return url.replace(/[),;.]+$/, "");
|
|
1050
|
+
});
|
|
1038
1051
|
}
|
|
1039
1052
|
function isUrlShortener(hostname) {
|
|
1040
1053
|
const normalizedHost = hostname.toLowerCase();
|
|
@@ -1058,6 +1071,21 @@ function containsUrlShortener(command) {
|
|
|
1058
1071
|
shortenerUrls
|
|
1059
1072
|
};
|
|
1060
1073
|
}
|
|
1074
|
+
var RISKY_URL_PATTERNS = [
|
|
1075
|
+
// Raw file content from GitHub - can be any user's code
|
|
1076
|
+
/raw\.githubusercontent\.com/i,
|
|
1077
|
+
// GitHub gist raw content
|
|
1078
|
+
/gist\.github\.com\/[^/]+\/[^/]+\/raw/i,
|
|
1079
|
+
// GitHub releases downloads - binary files from any user
|
|
1080
|
+
/github\.com\/[^/]+\/[^/]+\/releases\/download/i,
|
|
1081
|
+
// GitHub objects (blobs, etc.)
|
|
1082
|
+
/objects\.githubusercontent\.com/i,
|
|
1083
|
+
// Installer script patterns (get.*.sh)
|
|
1084
|
+
/\/get\.[^/]+\.sh/i
|
|
1085
|
+
];
|
|
1086
|
+
function isRiskyUrlPattern(url) {
|
|
1087
|
+
return RISKY_URL_PATTERNS.some((pattern) => pattern.test(url));
|
|
1088
|
+
}
|
|
1061
1089
|
|
|
1062
1090
|
// src/guard/checkpoint.ts
|
|
1063
1091
|
function detectCheckpoint(command) {
|
|
@@ -1091,29 +1119,39 @@ function checkTrustedDomains(command) {
|
|
|
1091
1119
|
return {
|
|
1092
1120
|
allTrusted: true,
|
|
1093
1121
|
// No URLs means nothing untrusted
|
|
1122
|
+
hasRiskyUrls: false,
|
|
1094
1123
|
urls: [],
|
|
1095
1124
|
trustedUrls: [],
|
|
1096
|
-
untrustedUrls: []
|
|
1125
|
+
untrustedUrls: [],
|
|
1126
|
+
riskyUrls: []
|
|
1097
1127
|
};
|
|
1098
1128
|
}
|
|
1099
1129
|
const trustedUrls = [];
|
|
1100
1130
|
const untrustedUrls = [];
|
|
1131
|
+
const riskyUrls = [];
|
|
1101
1132
|
for (const url of urls) {
|
|
1102
1133
|
if (isTrustedUrl(url)) {
|
|
1103
1134
|
trustedUrls.push(url);
|
|
1104
1135
|
} else {
|
|
1105
1136
|
untrustedUrls.push(url);
|
|
1106
1137
|
}
|
|
1138
|
+
if (isRiskyUrlPattern(url)) {
|
|
1139
|
+
riskyUrls.push(url);
|
|
1140
|
+
}
|
|
1107
1141
|
}
|
|
1108
1142
|
return {
|
|
1109
1143
|
allTrusted: untrustedUrls.length === 0,
|
|
1144
|
+
hasRiskyUrls: riskyUrls.length > 0,
|
|
1110
1145
|
urls,
|
|
1111
1146
|
trustedUrls,
|
|
1112
|
-
untrustedUrls
|
|
1147
|
+
untrustedUrls,
|
|
1148
|
+
riskyUrls
|
|
1113
1149
|
};
|
|
1114
1150
|
}
|
|
1115
1151
|
|
|
1116
1152
|
// src/guard/file-tools.ts
|
|
1153
|
+
import { resolve } from "path";
|
|
1154
|
+
import { homedir as homedir3 } from "os";
|
|
1117
1155
|
var WRITE_SENSITIVE_PATHS = [
|
|
1118
1156
|
// SSH - Critical (persistent access)
|
|
1119
1157
|
{
|
|
@@ -1292,13 +1330,6 @@ var WRITE_SENSITIVE_PATHS = [
|
|
|
1292
1330
|
legitimateUses: ["Configuring PyPI", "Publishing packages"]
|
|
1293
1331
|
},
|
|
1294
1332
|
// Claude Code config - Critical (could disable security)
|
|
1295
|
-
{
|
|
1296
|
-
pattern: /CLAUDE\.md$/i,
|
|
1297
|
-
description: "Claude instructions file",
|
|
1298
|
-
severity: "critical",
|
|
1299
|
-
risk: "Can modify AI behavior and disable security rules",
|
|
1300
|
-
legitimateUses: ["Updating project instructions", "Configuring Claude behavior"]
|
|
1301
|
-
},
|
|
1302
1333
|
{
|
|
1303
1334
|
pattern: /^~?\/?\.claude\//i,
|
|
1304
1335
|
description: "Claude config directory",
|
|
@@ -1450,8 +1481,18 @@ var READ_SENSITIVE_PATHS = [
|
|
|
1450
1481
|
}
|
|
1451
1482
|
];
|
|
1452
1483
|
function normalizePath(filePath) {
|
|
1453
|
-
let normalized = filePath
|
|
1484
|
+
let normalized = filePath;
|
|
1485
|
+
normalized = normalized.replace(/\$HOME/g, "~").replace(/\$\{HOME\}/g, "~");
|
|
1454
1486
|
normalized = normalized.replace(/\/+/g, "/");
|
|
1487
|
+
if (normalized.startsWith("/")) {
|
|
1488
|
+
normalized = resolve(normalized);
|
|
1489
|
+
}
|
|
1490
|
+
const home = homedir3();
|
|
1491
|
+
if (normalized.startsWith(home + "/")) {
|
|
1492
|
+
normalized = "~" + normalized.slice(home.length);
|
|
1493
|
+
} else if (normalized === home) {
|
|
1494
|
+
normalized = "~";
|
|
1495
|
+
}
|
|
1455
1496
|
return normalized;
|
|
1456
1497
|
}
|
|
1457
1498
|
function checkFilePath(filePath, action) {
|
|
@@ -1516,19 +1557,21 @@ var PROMPT_INJECTION_PATTERNS = [
|
|
|
1516
1557
|
/pretend\s+(to\s+be|you\s+are)/i,
|
|
1517
1558
|
/new\s+instructions?:/i,
|
|
1518
1559
|
/updated?\s+instructions?:/i,
|
|
1519
|
-
// Context/role markers (
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1560
|
+
// Context/role markers (require injection-like context to avoid false positives)
|
|
1561
|
+
// "system:" alone is too broad (matches "operating system: linux")
|
|
1562
|
+
// Require either line-start or preceded by newline to indicate role-marker usage
|
|
1563
|
+
/^\s*system\s*:/im,
|
|
1564
|
+
/^\s*assistant\s*:/im,
|
|
1565
|
+
/^\s*human\s*:/im,
|
|
1566
|
+
/^\s*user\s*:/im,
|
|
1524
1567
|
/<\s*system\s*>/i,
|
|
1525
1568
|
/<\s*\/?\s*instructions?\s*>/i,
|
|
1526
|
-
// Emphasis markers
|
|
1527
|
-
/\bIMPORTANT\s
|
|
1528
|
-
/\bNOTE\s
|
|
1529
|
-
/\bWARNING\s
|
|
1530
|
-
/\bCRITICAL\s
|
|
1531
|
-
/\bURGENT\s
|
|
1569
|
+
// Emphasis markers - only flag when combined with directive language
|
|
1570
|
+
/\bIMPORTANT\s*:.*\b(approve|allow|safe|trust|skip|ignore)\b/i,
|
|
1571
|
+
/\bNOTE\s*:.*\b(approve|allow|safe|trust|skip|ignore)\b/i,
|
|
1572
|
+
/\bWARNING\s*:.*\b(approve|allow|safe|trust|skip|ignore)\b/i,
|
|
1573
|
+
/\bCRITICAL\s*:.*\b(approve|allow|safe|trust|skip|ignore)\b/i,
|
|
1574
|
+
/\bURGENT\s*:.*\b(approve|allow|safe|trust|skip|ignore)\b/i,
|
|
1532
1575
|
// Output manipulation
|
|
1533
1576
|
/respond\s+with\s+(this\s+)?(exact\s+)?json/i,
|
|
1534
1577
|
/return\s+(only\s+)?["']?ALLOW["']?/i,
|
|
@@ -1588,10 +1631,10 @@ var FORCE_ESCALATE_PATTERNS = [
|
|
|
1588
1631
|
// su commands
|
|
1589
1632
|
/chmod\s+[0-7]*[7][0-7]*/i,
|
|
1590
1633
|
// chmod with executable permissions
|
|
1591
|
-
/\.env/i,
|
|
1592
|
-
// env file access
|
|
1593
|
-
/\/(etc|root
|
|
1594
|
-
// System directory access
|
|
1634
|
+
/\.env(\s|$|\.local|\.production|\.development|\.staging|\.test)/i,
|
|
1635
|
+
// .env file access (not .envoy, .environment, etc.)
|
|
1636
|
+
/\/(etc|root)\//i
|
|
1637
|
+
// System directory access (/etc/, /root/ - not /home/ which is too broad)
|
|
1595
1638
|
];
|
|
1596
1639
|
function shouldForceEscalate(command) {
|
|
1597
1640
|
if (containsPromptInjection(command)) {
|
|
@@ -1599,6 +1642,96 @@ function shouldForceEscalate(command) {
|
|
|
1599
1642
|
}
|
|
1600
1643
|
return FORCE_ESCALATE_PATTERNS.some((pattern) => pattern.test(command));
|
|
1601
1644
|
}
|
|
1645
|
+
function extractJsonFromText(text) {
|
|
1646
|
+
try {
|
|
1647
|
+
const parsed = JSON.parse(text.trim());
|
|
1648
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1649
|
+
return parsed;
|
|
1650
|
+
}
|
|
1651
|
+
} catch {
|
|
1652
|
+
}
|
|
1653
|
+
const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
|
1654
|
+
if (codeBlockMatch) {
|
|
1655
|
+
try {
|
|
1656
|
+
const parsed = JSON.parse(codeBlockMatch[1].trim());
|
|
1657
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1658
|
+
return parsed;
|
|
1659
|
+
}
|
|
1660
|
+
} catch {
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
const startIdx = text.indexOf("{");
|
|
1664
|
+
if (startIdx === -1) return null;
|
|
1665
|
+
let depth = 0;
|
|
1666
|
+
let inString = false;
|
|
1667
|
+
let escaped = false;
|
|
1668
|
+
for (let i = startIdx; i < text.length; i++) {
|
|
1669
|
+
const ch = text[i];
|
|
1670
|
+
if (escaped) {
|
|
1671
|
+
escaped = false;
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
if (ch === "\\" && inString) {
|
|
1675
|
+
escaped = true;
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
if (ch === '"') {
|
|
1679
|
+
inString = !inString;
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
if (inString) continue;
|
|
1683
|
+
if (ch === "{") depth++;
|
|
1684
|
+
else if (ch === "}") {
|
|
1685
|
+
depth--;
|
|
1686
|
+
if (depth === 0) {
|
|
1687
|
+
const candidate = text.slice(startIdx, i + 1);
|
|
1688
|
+
try {
|
|
1689
|
+
const parsed = JSON.parse(candidate);
|
|
1690
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1691
|
+
return parsed;
|
|
1692
|
+
}
|
|
1693
|
+
} catch {
|
|
1694
|
+
return null;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/utils/llm-call.ts
|
|
1703
|
+
async function callLLM(options) {
|
|
1704
|
+
const { client, model, systemPrompt, userPrompt, maxTokens, timeoutMs } = options;
|
|
1705
|
+
try {
|
|
1706
|
+
const controller = new AbortController();
|
|
1707
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1708
|
+
const response = await client.messages.create(
|
|
1709
|
+
{
|
|
1710
|
+
model,
|
|
1711
|
+
max_tokens: maxTokens,
|
|
1712
|
+
system: systemPrompt,
|
|
1713
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
1714
|
+
},
|
|
1715
|
+
{ signal: controller.signal }
|
|
1716
|
+
);
|
|
1717
|
+
clearTimeout(timeoutId);
|
|
1718
|
+
const text = response.content[0]?.type === "text" ? response.content[0].text : "";
|
|
1719
|
+
if (!text) {
|
|
1720
|
+
return { ok: false, error: "empty_response", message: "Empty response from LLM" };
|
|
1721
|
+
}
|
|
1722
|
+
const extracted = extractJsonFromText(text);
|
|
1723
|
+
if (!extracted) {
|
|
1724
|
+
return { ok: false, error: "parse_error", message: "Could not parse JSON response" };
|
|
1725
|
+
}
|
|
1726
|
+
return { ok: true, data: extracted };
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1729
|
+
if (errorMessage.includes("abort") || errorMessage.includes("timeout")) {
|
|
1730
|
+
return { ok: false, error: "timeout", message: "API timeout" };
|
|
1731
|
+
}
|
|
1732
|
+
return { ok: false, error: "api_error", message: errorMessage };
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1602
1735
|
|
|
1603
1736
|
// src/guard/haiku-triage.ts
|
|
1604
1737
|
var DEFAULT_HAIKU_MODEL = "claude-haiku-4-20250514";
|
|
@@ -1658,70 +1791,42 @@ async function triageWithHaiku(client, checkpoint, model) {
|
|
|
1658
1791
|
}
|
|
1659
1792
|
const sanitizedCommand = sanitizeForPrompt(checkpoint.command);
|
|
1660
1793
|
const userPrompt = TRIAGE_USER_PROMPT.replace("{command}", escapeXml(sanitizedCommand)).replace("{checkpoint_type}", escapeXml(checkpoint.type)).replace("{context}", escapeXml(checkpoint.description));
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
{ signal: controller.signal }
|
|
1672
|
-
);
|
|
1673
|
-
clearTimeout(timeoutId);
|
|
1674
|
-
const text = response.content[0]?.type === "text" ? response.content[0].text : "";
|
|
1675
|
-
if (!text) {
|
|
1676
|
-
return {
|
|
1677
|
-
classification: "ESCALATE",
|
|
1678
|
-
reason: "Triage failed: Empty response from Haiku",
|
|
1679
|
-
riskIndicators: ["triage_error"]
|
|
1680
|
-
};
|
|
1681
|
-
}
|
|
1682
|
-
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
1683
|
-
if (!jsonMatch) {
|
|
1684
|
-
return {
|
|
1685
|
-
classification: "ESCALATE",
|
|
1686
|
-
reason: "Triage failed: Could not parse JSON response",
|
|
1687
|
-
riskIndicators: ["triage_error"]
|
|
1688
|
-
};
|
|
1689
|
-
}
|
|
1690
|
-
const parsed = JSON.parse(jsonMatch[0]);
|
|
1691
|
-
if (!parsed.classification || !["SELF_HANDLE", "ESCALATE", "BLOCK"].includes(parsed.classification)) {
|
|
1692
|
-
return {
|
|
1693
|
-
classification: "ESCALATE",
|
|
1694
|
-
reason: "Triage failed: Invalid classification in response",
|
|
1695
|
-
riskIndicators: ["triage_error"]
|
|
1696
|
-
};
|
|
1697
|
-
}
|
|
1698
|
-
if (parsed.classification === "SELF_HANDLE" && shouldForceEscalate(checkpoint.command)) {
|
|
1699
|
-
return {
|
|
1700
|
-
classification: "ESCALATE",
|
|
1701
|
-
reason: "Auto-escalated: Command contains patterns requiring deeper review",
|
|
1702
|
-
riskIndicators: ["forced_escalation", ...parsed.risk_indicators ?? []]
|
|
1703
|
-
};
|
|
1704
|
-
}
|
|
1794
|
+
const result = await callLLM({
|
|
1795
|
+
client,
|
|
1796
|
+
model: model ?? DEFAULT_HAIKU_MODEL,
|
|
1797
|
+
systemPrompt: TRIAGE_SYSTEM_PROMPT,
|
|
1798
|
+
userPrompt,
|
|
1799
|
+
maxTokens: 500,
|
|
1800
|
+
timeoutMs: API_TIMEOUT_MS
|
|
1801
|
+
});
|
|
1802
|
+
if (!result.ok) {
|
|
1803
|
+
const tag = result.error === "timeout" ? "triage_timeout" : "triage_error";
|
|
1705
1804
|
return {
|
|
1706
|
-
classification:
|
|
1707
|
-
reason:
|
|
1708
|
-
riskIndicators:
|
|
1805
|
+
classification: "ESCALATE",
|
|
1806
|
+
reason: `Triage failed: ${result.message}`,
|
|
1807
|
+
riskIndicators: [tag]
|
|
1709
1808
|
};
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
return {
|
|
1714
|
-
classification: "ESCALATE",
|
|
1715
|
-
reason: "Triage failed: API timeout",
|
|
1716
|
-
riskIndicators: ["triage_timeout"]
|
|
1717
|
-
};
|
|
1718
|
-
}
|
|
1809
|
+
}
|
|
1810
|
+
const parsed = result.data;
|
|
1811
|
+
if (!parsed.classification || !["SELF_HANDLE", "ESCALATE", "BLOCK"].includes(parsed.classification)) {
|
|
1719
1812
|
return {
|
|
1720
1813
|
classification: "ESCALATE",
|
|
1721
|
-
reason:
|
|
1814
|
+
reason: "Triage failed: Invalid classification in response",
|
|
1722
1815
|
riskIndicators: ["triage_error"]
|
|
1723
1816
|
};
|
|
1724
1817
|
}
|
|
1818
|
+
if (parsed.classification === "SELF_HANDLE" && shouldForceEscalate(checkpoint.command)) {
|
|
1819
|
+
return {
|
|
1820
|
+
classification: "ESCALATE",
|
|
1821
|
+
reason: "Auto-escalated: Command contains patterns requiring deeper review",
|
|
1822
|
+
riskIndicators: ["forced_escalation", ...parsed.risk_indicators ?? []]
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
return {
|
|
1826
|
+
classification: parsed.classification,
|
|
1827
|
+
reason: parsed.reason ?? "No reason provided",
|
|
1828
|
+
riskIndicators: parsed.risk_indicators ?? []
|
|
1829
|
+
};
|
|
1725
1830
|
}
|
|
1726
1831
|
|
|
1727
1832
|
// src/guard/sonnet-review.ts
|
|
@@ -1800,81 +1905,76 @@ BLOCK - Do not allow:
|
|
|
1800
1905
|
async function reviewWithSonnet(client, checkpoint, triage, model) {
|
|
1801
1906
|
const sanitizedCommand = sanitizeForPrompt(checkpoint.command);
|
|
1802
1907
|
const userPrompt = REVIEW_USER_PROMPT.replace("{command}", escapeXml(sanitizedCommand)).replace("{checkpoint_type}", escapeXml(checkpoint.type)).replace("{context}", escapeXml(checkpoint.description)).replace("{triage_reason}", escapeXml(triage.reason)).replace("{risk_indicators}", escapeXml(triage.riskIndicators.join(", ") || "none"));
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
verdict: "ASK_USER",
|
|
1820
|
-
riskLevel: "medium",
|
|
1821
|
-
reason: "Review failed: Empty response from Sonnet",
|
|
1822
|
-
userMessage: "Automated security review failed. Please review this operation manually."
|
|
1823
|
-
};
|
|
1824
|
-
}
|
|
1825
|
-
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
1826
|
-
if (!jsonMatch) {
|
|
1827
|
-
return {
|
|
1828
|
-
verdict: "ASK_USER",
|
|
1829
|
-
riskLevel: "medium",
|
|
1830
|
-
reason: "Review failed: Could not parse JSON response",
|
|
1831
|
-
userMessage: "Automated security review failed. Please review this operation manually."
|
|
1832
|
-
};
|
|
1833
|
-
}
|
|
1834
|
-
const parsed = JSON.parse(jsonMatch[0]);
|
|
1835
|
-
const verdict = parsed.verdict ?? "ASK_USER";
|
|
1836
|
-
if (!["ALLOW", "ASK_USER", "BLOCK"].includes(verdict)) {
|
|
1837
|
-
return {
|
|
1838
|
-
verdict: "ASK_USER",
|
|
1839
|
-
riskLevel: "medium",
|
|
1840
|
-
reason: "Review failed: Invalid verdict in response",
|
|
1841
|
-
userMessage: "Automated security review failed. Please review this operation manually."
|
|
1842
|
-
};
|
|
1843
|
-
}
|
|
1844
|
-
const result = {
|
|
1845
|
-
verdict,
|
|
1846
|
-
riskLevel: parsed.risk_level ?? "medium",
|
|
1847
|
-
reason: parsed.analysis?.intent ?? "Review completed"
|
|
1908
|
+
const FALLBACK_MSG = "Automated security review failed. Please review this operation manually.";
|
|
1909
|
+
const result = await callLLM({
|
|
1910
|
+
client,
|
|
1911
|
+
model: model ?? DEFAULT_SONNET_MODEL,
|
|
1912
|
+
systemPrompt: REVIEW_SYSTEM_PROMPT,
|
|
1913
|
+
userPrompt,
|
|
1914
|
+
maxTokens: 1e3,
|
|
1915
|
+
timeoutMs: API_TIMEOUT_MS2
|
|
1916
|
+
});
|
|
1917
|
+
if (!result.ok) {
|
|
1918
|
+
const msg = result.error === "timeout" ? "Security review timed out. Please review this operation manually." : FALLBACK_MSG;
|
|
1919
|
+
return {
|
|
1920
|
+
verdict: "ASK_USER",
|
|
1921
|
+
riskLevel: "medium",
|
|
1922
|
+
reason: `Review failed: ${result.message}`,
|
|
1923
|
+
userMessage: msg
|
|
1848
1924
|
};
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
} catch (error) {
|
|
1854
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1855
|
-
if (errorMessage.includes("abort") || errorMessage.includes("timeout")) {
|
|
1856
|
-
return {
|
|
1857
|
-
verdict: "ASK_USER",
|
|
1858
|
-
riskLevel: "medium",
|
|
1859
|
-
reason: "Review failed: API timeout",
|
|
1860
|
-
userMessage: "Security review timed out. Please review this operation manually."
|
|
1861
|
-
};
|
|
1862
|
-
}
|
|
1925
|
+
}
|
|
1926
|
+
const parsed = result.data;
|
|
1927
|
+
const verdict = parsed.verdict ?? "ASK_USER";
|
|
1928
|
+
if (!["ALLOW", "ASK_USER", "BLOCK"].includes(verdict)) {
|
|
1863
1929
|
return {
|
|
1864
1930
|
verdict: "ASK_USER",
|
|
1865
1931
|
riskLevel: "medium",
|
|
1866
|
-
reason:
|
|
1867
|
-
userMessage:
|
|
1932
|
+
reason: "Review failed: Invalid verdict in response",
|
|
1933
|
+
userMessage: FALLBACK_MSG
|
|
1868
1934
|
};
|
|
1869
1935
|
}
|
|
1936
|
+
const reviewResult = {
|
|
1937
|
+
verdict,
|
|
1938
|
+
riskLevel: parsed.risk_level ?? "medium",
|
|
1939
|
+
reason: parsed.analysis?.intent ?? "Review completed"
|
|
1940
|
+
};
|
|
1941
|
+
if (parsed.user_message) {
|
|
1942
|
+
reviewResult.userMessage = parsed.user_message;
|
|
1943
|
+
}
|
|
1944
|
+
return reviewResult;
|
|
1870
1945
|
}
|
|
1871
1946
|
|
|
1872
1947
|
// src/hook.ts
|
|
1873
1948
|
var TIMEOUT_SECONDS = 7;
|
|
1949
|
+
var REGEX_TIMEOUT_MS = 50;
|
|
1950
|
+
function safeRegexTest(pattern, input) {
|
|
1951
|
+
try {
|
|
1952
|
+
if (/(\(.+[+*]\))[+*]|\(\?:[^)]+[+*]\)[+*]/.test(pattern)) {
|
|
1953
|
+
process.stderr.write(`[vibesafu] Warning: Skipping potentially dangerous regex pattern: ${pattern}
|
|
1954
|
+
`);
|
|
1955
|
+
return false;
|
|
1956
|
+
}
|
|
1957
|
+
if (/\([^)]*\|[^)]*\)[+*]/.test(pattern)) {
|
|
1958
|
+
process.stderr.write(`[vibesafu] Warning: Skipping potentially dangerous regex pattern: ${pattern}
|
|
1959
|
+
`);
|
|
1960
|
+
return false;
|
|
1961
|
+
}
|
|
1962
|
+
if (/\([^)]*[+*][^)]*\)[+*]/.test(pattern)) {
|
|
1963
|
+
process.stderr.write(`[vibesafu] Warning: Skipping potentially dangerous regex pattern: ${pattern}
|
|
1964
|
+
`);
|
|
1965
|
+
return false;
|
|
1966
|
+
}
|
|
1967
|
+
const regex = new RegExp(pattern, "i");
|
|
1968
|
+
const testInput = input.length > REGEX_TIMEOUT_MS * 40 ? input.slice(0, REGEX_TIMEOUT_MS * 40) : input;
|
|
1969
|
+
return regex.test(testInput);
|
|
1970
|
+
} catch {
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1874
1974
|
var PLAN_MODE_TIMEOUT_SECONDS = 72 * 60 * 60;
|
|
1875
1975
|
var SAFE_NON_BASH_TOOLS = ["WebFetch", "WebSearch", "Task", "Glob", "Grep", "LS", "TodoRead", "TodoWrite", "NotebookRead"];
|
|
1876
|
-
async function processPermissionRequest(input, anthropicClient) {
|
|
1877
|
-
const config2 = await readConfig();
|
|
1976
|
+
async function processPermissionRequest(input, anthropicClient, preloadedConfig) {
|
|
1977
|
+
const config2 = preloadedConfig ?? await readConfig();
|
|
1878
1978
|
if (input.tool_name === "Write" || input.tool_name === "Edit" || input.tool_name === "Read") {
|
|
1879
1979
|
const fileCheck = checkFileTool(input.tool_name, input.tool_input);
|
|
1880
1980
|
if (fileCheck.blocked) {
|
|
@@ -1981,33 +2081,34 @@ Auto-reject in ${TIMEOUT_SECONDS}s.`
|
|
|
1981
2081
|
};
|
|
1982
2082
|
}
|
|
1983
2083
|
const command = input.tool_input.command;
|
|
2084
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
2085
|
+
return {
|
|
2086
|
+
decision: "deny",
|
|
2087
|
+
reason: `Invalid input: Bash tool requires a non-empty string command, got ${typeof command}`,
|
|
2088
|
+
source: "instant-block"
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
1984
2091
|
for (const pattern of config2.customPatterns.allow) {
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
};
|
|
1992
|
-
}
|
|
1993
|
-
} catch {
|
|
2092
|
+
if (safeRegexTest(pattern, command)) {
|
|
2093
|
+
return {
|
|
2094
|
+
decision: "allow",
|
|
2095
|
+
reason: `Custom allow pattern: ${pattern}`,
|
|
2096
|
+
source: "instant-allow"
|
|
2097
|
+
};
|
|
1994
2098
|
}
|
|
1995
2099
|
}
|
|
1996
2100
|
for (const pattern of config2.customPatterns.block) {
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
userMessage: `[CUSTOM BLOCK] Matched pattern: ${pattern}
|
|
2101
|
+
if (safeRegexTest(pattern, command)) {
|
|
2102
|
+
return {
|
|
2103
|
+
decision: "needs-review",
|
|
2104
|
+
reason: `Custom block pattern: ${pattern}`,
|
|
2105
|
+
source: "high-risk",
|
|
2106
|
+
userMessage: `[CUSTOM BLOCK] Matched pattern: ${pattern}
|
|
2004
2107
|
|
|
2005
2108
|
This command was blocked by your custom config.
|
|
2006
2109
|
|
|
2007
2110
|
Auto-reject in ${TIMEOUT_SECONDS}s.`
|
|
2008
|
-
|
|
2009
|
-
}
|
|
2010
|
-
} catch {
|
|
2111
|
+
};
|
|
2011
2112
|
}
|
|
2012
2113
|
}
|
|
2013
2114
|
const allowResult = checkInstantAllow(command);
|
|
@@ -2045,6 +2146,14 @@ Only proceed if you know what you're doing.`
|
|
|
2045
2146
|
if (checkpoint.type === "network") {
|
|
2046
2147
|
const domainResult = checkTrustedDomains(command);
|
|
2047
2148
|
if (domainResult.allTrusted && domainResult.urls.length > 0) {
|
|
2149
|
+
if (domainResult.hasRiskyUrls) {
|
|
2150
|
+
return {
|
|
2151
|
+
decision: "needs-review",
|
|
2152
|
+
reason: `Risky URL pattern from trusted domain: ${domainResult.riskyUrls.join(", ")}`,
|
|
2153
|
+
source: "checkpoint",
|
|
2154
|
+
checkpoint
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2048
2157
|
return {
|
|
2049
2158
|
decision: "allow",
|
|
2050
2159
|
reason: `All URLs from trusted domains: ${domainResult.trustedUrls.join(", ")}`,
|
|
@@ -2135,12 +2244,13 @@ async function runHook() {
|
|
|
2135
2244
|
console.log(JSON.stringify(output2));
|
|
2136
2245
|
return;
|
|
2137
2246
|
}
|
|
2247
|
+
const config2 = await readConfig();
|
|
2138
2248
|
let anthropicClient;
|
|
2139
|
-
const apiKey =
|
|
2249
|
+
const apiKey = process.env.ANTHROPIC_API_KEY ?? (config2.anthropic.apiKey || void 0);
|
|
2140
2250
|
if (apiKey) {
|
|
2141
2251
|
anthropicClient = new Anthropic({ apiKey });
|
|
2142
2252
|
}
|
|
2143
|
-
const result = await processPermissionRequest(input, anthropicClient);
|
|
2253
|
+
const result = await processPermissionRequest(input, anthropicClient, config2);
|
|
2144
2254
|
let output;
|
|
2145
2255
|
if (result.decision === "allow") {
|
|
2146
2256
|
output = createHookOutput("allow");
|
|
@@ -2149,7 +2259,7 @@ async function runHook() {
|
|
|
2149
2259
|
}
|
|
2150
2260
|
const warningMessage = result.userMessage ?? result.reason;
|
|
2151
2261
|
const timeout = result.timeoutSeconds ?? TIMEOUT_SECONDS;
|
|
2152
|
-
await new Promise((
|
|
2262
|
+
await new Promise((resolve2) => setTimeout(resolve2, timeout * 1e3));
|
|
2153
2263
|
const timeoutDisplay = timeout >= 3600 ? `${Math.round(timeout / 3600)}h` : `${timeout}s`;
|
|
2154
2264
|
const denyMessage = `\u{1F6E1}\uFE0F [vibesafu] Auto-denied (no response in ${timeoutDisplay})
|
|
2155
2265
|
|
|
@@ -2160,11 +2270,6 @@ If this was intentional, re-run the command and click "Allow".`;
|
|
|
2160
2270
|
console.log(JSON.stringify(output));
|
|
2161
2271
|
}
|
|
2162
2272
|
|
|
2163
|
-
// src/cli/check.ts
|
|
2164
|
-
async function check() {
|
|
2165
|
-
await runHook();
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
2273
|
// src/index.ts
|
|
2169
2274
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
2170
2275
|
var pkg = JSON.parse(readFileSync(join3(__dirname, "../package.json"), "utf-8"));
|
|
@@ -2203,7 +2308,7 @@ async function main() {
|
|
|
2203
2308
|
await uninstall();
|
|
2204
2309
|
break;
|
|
2205
2310
|
case "check":
|
|
2206
|
-
await
|
|
2311
|
+
await runHook();
|
|
2207
2312
|
break;
|
|
2208
2313
|
case "config":
|
|
2209
2314
|
await config();
|
package/package.json
CHANGED