vibesafu 0.1.25 → 0.1.26

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 +179 -59
  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";
@@ -123,24 +129,30 @@ async function readConfig() {
123
129
  try {
124
130
  const content = await readFile2(CONFIG_PATH, "utf-8");
125
131
  return mergeConfig(DEFAULT_CONFIG, JSON.parse(content));
126
- } catch {
132
+ } catch (error) {
133
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
134
+ return DEFAULT_CONFIG;
135
+ }
136
+ const msg = error instanceof Error ? error.message : "Unknown error";
137
+ process.stderr.write(`[vibesafu] Warning: Failed to read config (${CONFIG_PATH}): ${msg}. Using defaults.
138
+ `);
127
139
  return DEFAULT_CONFIG;
128
140
  }
129
141
  }
130
142
  async function writeConfig(config2) {
131
143
  await mkdir2(CONFIG_DIR, { recursive: true });
132
144
  await writeFile2(CONFIG_PATH, JSON.stringify(config2, null, 2));
133
- await chmod(CONFIG_PATH, 384);
145
+ await chmod2(CONFIG_PATH, 384);
134
146
  }
135
147
  function prompt(question) {
136
148
  const rl = createInterface({
137
149
  input: process.stdin,
138
150
  output: process.stdout
139
151
  });
140
- return new Promise((resolve) => {
152
+ return new Promise((resolve2) => {
141
153
  rl.question(question, (answer) => {
142
154
  rl.close();
143
- resolve(answer);
155
+ resolve2(answer);
144
156
  });
145
157
  });
146
158
  }
@@ -166,16 +178,6 @@ async function config() {
166
178
  console.log("Configuration saved!");
167
179
  console.log(`Config file: ${CONFIG_PATH}`);
168
180
  }
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
181
 
180
182
  // src/hook.ts
181
183
  import Anthropic from "@anthropic-ai/sdk";
@@ -779,6 +781,13 @@ var INSTANT_BLOCK_PATTERNS = [
779
781
  ...DESTRUCTIVE_PATTERNS,
780
782
  ...SELF_PROTECTION_PATTERNS
781
783
  ];
784
+ for (const p of INSTANT_BLOCK_PATTERNS) {
785
+ if (p.pattern.global) {
786
+ throw new Error(
787
+ `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.`
788
+ );
789
+ }
790
+ }
782
791
  var CHECKPOINT_PATTERNS = [
783
792
  // Script execution
784
793
  { pattern: /curl\s+.*\|\s*(ba)?sh/i, type: "script_execution", description: "curl piped to shell" },
@@ -822,12 +831,18 @@ var CHECKPOINT_PATTERNS = [
822
831
  { pattern: /\.ssh/i, type: "file_sensitive", description: "SSH directory access" },
823
832
  { pattern: /\.aws/i, type: "file_sensitive", description: "AWS credentials access" },
824
833
  { pattern: /credentials/i, type: "file_sensitive", description: "Credentials file access" },
825
- { pattern: /CLAUDE\.md/i, type: "file_sensitive", description: "CLAUDE.md modification" },
826
834
  // Sensitive file copy/move (indirect path bypass)
827
835
  { pattern: /(cp|mv)\s+.*\.ssh\//i, type: "file_sensitive", description: "Copying/moving SSH files" },
828
836
  { pattern: /(cp|mv)\s+.*\.aws\//i, type: "file_sensitive", description: "Copying/moving AWS credentials" },
829
837
  { pattern: /(cp|mv)\s+.*\.env(\s|$)/i, type: "file_sensitive", description: "Copying/moving .env file" }
830
838
  ];
839
+ for (const p of CHECKPOINT_PATTERNS) {
840
+ if (p.pattern.global) {
841
+ throw new Error(
842
+ `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.`
843
+ );
844
+ }
845
+ }
831
846
 
832
847
  // src/guard/instant-block.ts
833
848
  function checkHighRiskPatterns(command) {
@@ -1034,7 +1049,10 @@ function isTrustedUrl(url) {
1034
1049
  function extractUrls(command) {
1035
1050
  const urlPattern = /https?:\/\/[^\s"'<>]+/gi;
1036
1051
  const matches = command.match(urlPattern);
1037
- return matches ?? [];
1052
+ if (!matches) return [];
1053
+ return matches.map((url) => {
1054
+ return url.replace(/[),;.]+$/, "");
1055
+ });
1038
1056
  }
1039
1057
  function isUrlShortener(hostname) {
1040
1058
  const normalizedHost = hostname.toLowerCase();
@@ -1058,6 +1076,21 @@ function containsUrlShortener(command) {
1058
1076
  shortenerUrls
1059
1077
  };
1060
1078
  }
1079
+ var RISKY_URL_PATTERNS = [
1080
+ // Raw file content from GitHub - can be any user's code
1081
+ /raw\.githubusercontent\.com/i,
1082
+ // GitHub gist raw content
1083
+ /gist\.github\.com\/[^/]+\/[^/]+\/raw/i,
1084
+ // GitHub releases downloads - binary files from any user
1085
+ /github\.com\/[^/]+\/[^/]+\/releases\/download/i,
1086
+ // GitHub objects (blobs, etc.)
1087
+ /objects\.githubusercontent\.com/i,
1088
+ // Installer script patterns (get.*.sh)
1089
+ /\/get\.[^/]+\.sh/i
1090
+ ];
1091
+ function isRiskyUrlPattern(url) {
1092
+ return RISKY_URL_PATTERNS.some((pattern) => pattern.test(url));
1093
+ }
1061
1094
 
1062
1095
  // src/guard/checkpoint.ts
1063
1096
  function detectCheckpoint(command) {
@@ -1091,29 +1124,39 @@ function checkTrustedDomains(command) {
1091
1124
  return {
1092
1125
  allTrusted: true,
1093
1126
  // No URLs means nothing untrusted
1127
+ hasRiskyUrls: false,
1094
1128
  urls: [],
1095
1129
  trustedUrls: [],
1096
- untrustedUrls: []
1130
+ untrustedUrls: [],
1131
+ riskyUrls: []
1097
1132
  };
1098
1133
  }
1099
1134
  const trustedUrls = [];
1100
1135
  const untrustedUrls = [];
1136
+ const riskyUrls = [];
1101
1137
  for (const url of urls) {
1102
1138
  if (isTrustedUrl(url)) {
1103
1139
  trustedUrls.push(url);
1104
1140
  } else {
1105
1141
  untrustedUrls.push(url);
1106
1142
  }
1143
+ if (isRiskyUrlPattern(url)) {
1144
+ riskyUrls.push(url);
1145
+ }
1107
1146
  }
1108
1147
  return {
1109
1148
  allTrusted: untrustedUrls.length === 0,
1149
+ hasRiskyUrls: riskyUrls.length > 0,
1110
1150
  urls,
1111
1151
  trustedUrls,
1112
- untrustedUrls
1152
+ untrustedUrls,
1153
+ riskyUrls
1113
1154
  };
1114
1155
  }
1115
1156
 
1116
1157
  // src/guard/file-tools.ts
1158
+ import { resolve } from "path";
1159
+ import { homedir as homedir3 } from "os";
1117
1160
  var WRITE_SENSITIVE_PATHS = [
1118
1161
  // SSH - Critical (persistent access)
1119
1162
  {
@@ -1292,13 +1335,6 @@ var WRITE_SENSITIVE_PATHS = [
1292
1335
  legitimateUses: ["Configuring PyPI", "Publishing packages"]
1293
1336
  },
1294
1337
  // 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
1338
  {
1303
1339
  pattern: /^~?\/?\.claude\//i,
1304
1340
  description: "Claude config directory",
@@ -1450,8 +1486,18 @@ var READ_SENSITIVE_PATHS = [
1450
1486
  }
1451
1487
  ];
1452
1488
  function normalizePath(filePath) {
1453
- let normalized = filePath.replace(/\$HOME/g, "~").replace(/\$\{HOME\}/g, "~");
1489
+ let normalized = filePath;
1490
+ normalized = normalized.replace(/\$HOME/g, "~").replace(/\$\{HOME\}/g, "~");
1454
1491
  normalized = normalized.replace(/\/+/g, "/");
1492
+ if (normalized.startsWith("/")) {
1493
+ normalized = resolve(normalized);
1494
+ }
1495
+ const home = homedir3();
1496
+ if (normalized.startsWith(home + "/")) {
1497
+ normalized = "~" + normalized.slice(home.length);
1498
+ } else if (normalized === home) {
1499
+ normalized = "~";
1500
+ }
1455
1501
  return normalized;
1456
1502
  }
1457
1503
  function checkFilePath(filePath, action) {
@@ -1599,6 +1645,62 @@ function shouldForceEscalate(command) {
1599
1645
  }
1600
1646
  return FORCE_ESCALATE_PATTERNS.some((pattern) => pattern.test(command));
1601
1647
  }
1648
+ function extractJsonFromText(text) {
1649
+ try {
1650
+ const parsed = JSON.parse(text.trim());
1651
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1652
+ return parsed;
1653
+ }
1654
+ } catch {
1655
+ }
1656
+ const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
1657
+ if (codeBlockMatch) {
1658
+ try {
1659
+ const parsed = JSON.parse(codeBlockMatch[1].trim());
1660
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1661
+ return parsed;
1662
+ }
1663
+ } catch {
1664
+ }
1665
+ }
1666
+ const startIdx = text.indexOf("{");
1667
+ if (startIdx === -1) return null;
1668
+ let depth = 0;
1669
+ let inString = false;
1670
+ let escaped = false;
1671
+ for (let i = startIdx; i < text.length; i++) {
1672
+ const ch = text[i];
1673
+ if (escaped) {
1674
+ escaped = false;
1675
+ continue;
1676
+ }
1677
+ if (ch === "\\" && inString) {
1678
+ escaped = true;
1679
+ continue;
1680
+ }
1681
+ if (ch === '"') {
1682
+ inString = !inString;
1683
+ continue;
1684
+ }
1685
+ if (inString) continue;
1686
+ if (ch === "{") depth++;
1687
+ else if (ch === "}") {
1688
+ depth--;
1689
+ if (depth === 0) {
1690
+ const candidate = text.slice(startIdx, i + 1);
1691
+ try {
1692
+ const parsed = JSON.parse(candidate);
1693
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1694
+ return parsed;
1695
+ }
1696
+ } catch {
1697
+ return null;
1698
+ }
1699
+ }
1700
+ }
1701
+ }
1702
+ return null;
1703
+ }
1602
1704
 
1603
1705
  // src/guard/haiku-triage.ts
1604
1706
  var DEFAULT_HAIKU_MODEL = "claude-haiku-4-20250514";
@@ -1679,15 +1781,15 @@ async function triageWithHaiku(client, checkpoint, model) {
1679
1781
  riskIndicators: ["triage_error"]
1680
1782
  };
1681
1783
  }
1682
- const jsonMatch = text.match(/\{[\s\S]*\}/);
1683
- if (!jsonMatch) {
1784
+ const extracted = extractJsonFromText(text);
1785
+ if (!extracted) {
1684
1786
  return {
1685
1787
  classification: "ESCALATE",
1686
1788
  reason: "Triage failed: Could not parse JSON response",
1687
1789
  riskIndicators: ["triage_error"]
1688
1790
  };
1689
1791
  }
1690
- const parsed = JSON.parse(jsonMatch[0]);
1792
+ const parsed = extracted;
1691
1793
  if (!parsed.classification || !["SELF_HANDLE", "ESCALATE", "BLOCK"].includes(parsed.classification)) {
1692
1794
  return {
1693
1795
  classification: "ESCALATE",
@@ -1822,8 +1924,8 @@ async function reviewWithSonnet(client, checkpoint, triage, model) {
1822
1924
  userMessage: "Automated security review failed. Please review this operation manually."
1823
1925
  };
1824
1926
  }
1825
- const jsonMatch = text.match(/\{[\s\S]*\}/);
1826
- if (!jsonMatch) {
1927
+ const extracted = extractJsonFromText(text);
1928
+ if (!extracted) {
1827
1929
  return {
1828
1930
  verdict: "ASK_USER",
1829
1931
  riskLevel: "medium",
@@ -1831,7 +1933,7 @@ async function reviewWithSonnet(client, checkpoint, triage, model) {
1831
1933
  userMessage: "Automated security review failed. Please review this operation manually."
1832
1934
  };
1833
1935
  }
1834
- const parsed = JSON.parse(jsonMatch[0]);
1936
+ const parsed = extracted;
1835
1937
  const verdict = parsed.verdict ?? "ASK_USER";
1836
1938
  if (!["ALLOW", "ASK_USER", "BLOCK"].includes(verdict)) {
1837
1939
  return {
@@ -1871,10 +1973,25 @@ async function reviewWithSonnet(client, checkpoint, triage, model) {
1871
1973
 
1872
1974
  // src/hook.ts
1873
1975
  var TIMEOUT_SECONDS = 7;
1976
+ var REGEX_TIMEOUT_MS = 50;
1977
+ function safeRegexTest(pattern, input) {
1978
+ try {
1979
+ const regex = new RegExp(pattern, "i");
1980
+ if (/(\(.+[+*]\))[+*]|\(\?:[^)]+[+*]\)[+*]/.test(pattern)) {
1981
+ process.stderr.write(`[vibesafu] Warning: Skipping potentially dangerous regex pattern: ${pattern}
1982
+ `);
1983
+ return false;
1984
+ }
1985
+ const testInput = input.length > REGEX_TIMEOUT_MS * 40 ? input.slice(0, REGEX_TIMEOUT_MS * 40) : input;
1986
+ return regex.test(testInput);
1987
+ } catch {
1988
+ return false;
1989
+ }
1990
+ }
1874
1991
  var PLAN_MODE_TIMEOUT_SECONDS = 72 * 60 * 60;
1875
1992
  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();
1993
+ async function processPermissionRequest(input, anthropicClient, preloadedConfig) {
1994
+ const config2 = preloadedConfig ?? await readConfig();
1878
1995
  if (input.tool_name === "Write" || input.tool_name === "Edit" || input.tool_name === "Read") {
1879
1996
  const fileCheck = checkFileTool(input.tool_name, input.tool_input);
1880
1997
  if (fileCheck.blocked) {
@@ -1982,32 +2099,26 @@ Auto-reject in ${TIMEOUT_SECONDS}s.`
1982
2099
  }
1983
2100
  const command = input.tool_input.command;
1984
2101
  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 {
2102
+ if (safeRegexTest(pattern, command)) {
2103
+ return {
2104
+ decision: "allow",
2105
+ reason: `Custom allow pattern: ${pattern}`,
2106
+ source: "instant-allow"
2107
+ };
1994
2108
  }
1995
2109
  }
1996
2110
  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}
2111
+ if (safeRegexTest(pattern, command)) {
2112
+ return {
2113
+ decision: "needs-review",
2114
+ reason: `Custom block pattern: ${pattern}`,
2115
+ source: "high-risk",
2116
+ userMessage: `[CUSTOM BLOCK] Matched pattern: ${pattern}
2004
2117
 
2005
2118
  This command was blocked by your custom config.
2006
2119
 
2007
2120
  Auto-reject in ${TIMEOUT_SECONDS}s.`
2008
- };
2009
- }
2010
- } catch {
2121
+ };
2011
2122
  }
2012
2123
  }
2013
2124
  const allowResult = checkInstantAllow(command);
@@ -2045,6 +2156,14 @@ Only proceed if you know what you're doing.`
2045
2156
  if (checkpoint.type === "network") {
2046
2157
  const domainResult = checkTrustedDomains(command);
2047
2158
  if (domainResult.allTrusted && domainResult.urls.length > 0) {
2159
+ if (domainResult.hasRiskyUrls) {
2160
+ return {
2161
+ decision: "needs-review",
2162
+ reason: `Risky URL pattern from trusted domain: ${domainResult.riskyUrls.join(", ")}`,
2163
+ source: "checkpoint",
2164
+ checkpoint
2165
+ };
2166
+ }
2048
2167
  return {
2049
2168
  decision: "allow",
2050
2169
  reason: `All URLs from trusted domains: ${domainResult.trustedUrls.join(", ")}`,
@@ -2135,12 +2254,13 @@ async function runHook() {
2135
2254
  console.log(JSON.stringify(output2));
2136
2255
  return;
2137
2256
  }
2257
+ const config2 = await readConfig();
2138
2258
  let anthropicClient;
2139
- const apiKey = await getApiKey();
2259
+ const apiKey = process.env.ANTHROPIC_API_KEY ?? (config2.anthropic.apiKey || void 0);
2140
2260
  if (apiKey) {
2141
2261
  anthropicClient = new Anthropic({ apiKey });
2142
2262
  }
2143
- const result = await processPermissionRequest(input, anthropicClient);
2263
+ const result = await processPermissionRequest(input, anthropicClient, config2);
2144
2264
  let output;
2145
2265
  if (result.decision === "allow") {
2146
2266
  output = createHookOutput("allow");
@@ -2149,7 +2269,7 @@ async function runHook() {
2149
2269
  }
2150
2270
  const warningMessage = result.userMessage ?? result.reason;
2151
2271
  const timeout = result.timeoutSeconds ?? TIMEOUT_SECONDS;
2152
- await new Promise((resolve) => setTimeout(resolve, timeout * 1e3));
2272
+ await new Promise((resolve2) => setTimeout(resolve2, timeout * 1e3));
2153
2273
  const timeoutDisplay = timeout >= 3600 ? `${Math.round(timeout / 3600)}h` : `${timeout}s`;
2154
2274
  const denyMessage = `\u{1F6E1}\uFE0F [vibesafu] Auto-denied (no response in ${timeoutDisplay})
2155
2275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibesafu",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
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",