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.
Files changed (2) hide show
  1. package/dist/index.js +304 -199
  2. 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 chmod(CONFIG_PATH, 384);
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((resolve) => {
147
+ return new Promise((resolve2) => {
141
148
  rl.question(question, (answer) => {
142
149
  rl.close();
143
- resolve(answer);
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
- return matches ?? [];
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.replace(/\$HOME/g, "~").replace(/\$\{HOME\}/g, "~");
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 (could be trying to inject fake context)
1520
- /system\s*:/i,
1521
- /assistant\s*:/i,
1522
- /human\s*:/i,
1523
- /user\s*:/i,
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 often used in injection
1527
- /\bIMPORTANT\s*:/i,
1528
- /\bNOTE\s*:/i,
1529
- /\bWARNING\s*:/i,
1530
- /\bCRITICAL\s*:/i,
1531
- /\bURGENT\s*:/i,
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|home)\//i
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
- try {
1662
- const controller = new AbortController();
1663
- const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
1664
- const response = await client.messages.create(
1665
- {
1666
- model: model ?? DEFAULT_HAIKU_MODEL,
1667
- max_tokens: 500,
1668
- system: TRIAGE_SYSTEM_PROMPT,
1669
- messages: [{ role: "user", content: userPrompt }]
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: parsed.classification,
1707
- reason: parsed.reason ?? "No reason provided",
1708
- riskIndicators: parsed.risk_indicators ?? []
1805
+ classification: "ESCALATE",
1806
+ reason: `Triage failed: ${result.message}`,
1807
+ riskIndicators: [tag]
1709
1808
  };
1710
- } catch (error) {
1711
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
1712
- if (errorMessage.includes("abort") || errorMessage.includes("timeout")) {
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: `Triage failed: ${errorMessage}`,
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
- try {
1804
- const controller = new AbortController();
1805
- const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS2);
1806
- const response = await client.messages.create(
1807
- {
1808
- model: model ?? DEFAULT_SONNET_MODEL,
1809
- max_tokens: 1e3,
1810
- system: REVIEW_SYSTEM_PROMPT,
1811
- messages: [{ role: "user", content: userPrompt }]
1812
- },
1813
- { signal: controller.signal }
1814
- );
1815
- clearTimeout(timeoutId);
1816
- const text = response.content[0]?.type === "text" ? response.content[0].text : "";
1817
- if (!text) {
1818
- return {
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
- if (parsed.user_message) {
1850
- result.userMessage = parsed.user_message;
1851
- }
1852
- return result;
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: `Review failed: ${errorMessage}`,
1867
- userMessage: "Automated security review failed. Please review this operation manually."
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
- try {
1986
- if (new RegExp(pattern, "i").test(command)) {
1987
- return {
1988
- decision: "allow",
1989
- reason: `Custom allow pattern: ${pattern}`,
1990
- source: "instant-allow"
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
- try {
1998
- if (new RegExp(pattern, "i").test(command)) {
1999
- return {
2000
- decision: "needs-review",
2001
- reason: `Custom block pattern: ${pattern}`,
2002
- source: "high-risk",
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 = await getApiKey();
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((resolve) => setTimeout(resolve, timeout * 1e3));
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 check();
2311
+ await runHook();
2207
2312
  break;
2208
2313
  case "config":
2209
2314
  await config();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibesafu",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Better Claude Code workflow with smart safety checks. Safe YOLO mode without --dangerously-skip-permission",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",