agen-vektor 0.3.11 → 0.3.13

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.
@@ -96,6 +96,19 @@ class AnthropicProvider {
96
96
  body.tools = toToolDefs(params.tools);
97
97
  if (params.temperature !== undefined)
98
98
  body.temperature = params.temperature;
99
+ // Extended thinking (Anthropic): ask for a reasoning budget so thinking
100
+ // models (Claude 3.7+/4 family) stream thinking_delta blocks — rendered
101
+ // as the • Thinking card. Harmless for non-thinking models? No — the API
102
+ // REJECTS thinking for models without extended-thinking support, so it
103
+ // is opt-in via VECTOR_ANTHROPIC_THINKING=1 (or a budget explicitly set
104
+ // here), keeping every other model working unchanged.
105
+ const wantThinking = process.env.VECTOR_ANTHROPIC_THINKING === '1';
106
+ const budget = Number(process.env.VECTOR_ANTHROPIC_THINKING_BUDGET || 0);
107
+ if (wantThinking || budget > 0) {
108
+ body.thinking = { type: 'enabled', budget_tokens: budget > 0 ? budget : 8_000 };
109
+ // Anthropic requires temperature unset (or 1) when thinking is on.
110
+ delete body.temperature;
111
+ }
99
112
  return body;
100
113
  }
101
114
  async post(body, signal) {
@@ -133,10 +146,13 @@ class AnthropicProvider {
133
146
  const data = await this.post(this.buildBody(params, false), params.signal);
134
147
  const content = data.content || [];
135
148
  let text = '';
149
+ let thinking = '';
136
150
  const toolCalls = [];
137
151
  for (const block of content) {
138
152
  if (block.type === 'text')
139
153
  text += String(block.text || '');
154
+ if (block.type === 'thinking')
155
+ thinking += String(block.thinking || '');
140
156
  if (block.type === 'tool_use') {
141
157
  toolCalls.push({
142
158
  id: String(block.id),
@@ -148,6 +164,7 @@ class AnthropicProvider {
148
164
  const usage = data.usage;
149
165
  return {
150
166
  content: text,
167
+ reasoning: thinking || undefined,
151
168
  toolCalls,
152
169
  stopReason: String(data.stop_reason || 'end_turn'),
153
170
  usage: usage
@@ -181,6 +198,7 @@ class AnthropicProvider {
181
198
  const decoder = new TextDecoder();
182
199
  let buffer = '';
183
200
  let content = '';
201
+ let thinking = '';
184
202
  const toolCalls = [];
185
203
  let currentTool = null;
186
204
  for (;;) {
@@ -210,12 +228,21 @@ class AnthropicProvider {
210
228
  arguments: '',
211
229
  };
212
230
  }
231
+ else if (evtName === 'content_block_start' && contentBlock?.type === 'thinking') {
232
+ // Thinking block starts — its deltas carry type thinking_delta.
233
+ }
213
234
  else if (evtName === 'content_block_delta') {
214
235
  const delta = json.delta;
215
236
  if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
216
237
  content += delta.text;
217
238
  yield { type: 'delta', delta: delta.text };
218
239
  }
240
+ else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
241
+ // Extended thinking (Claude 3.7+/4): surface as reasoning so
242
+ // the • Thinking card renders for these models too.
243
+ thinking += delta.thinking;
244
+ yield { type: 'delta', reasoning: delta.thinking };
245
+ }
219
246
  else if (delta?.type === 'input_json_delta' &&
220
247
  currentTool &&
221
248
  typeof delta.partial_json === 'string') {
@@ -252,7 +279,13 @@ class AnthropicProvider {
252
279
  toolCalls.push(currentTool);
253
280
  yield {
254
281
  type: 'done',
255
- result: { content, toolCalls, stopReason: 'end_turn', model: params.model },
282
+ result: {
283
+ content,
284
+ reasoning: thinking || undefined,
285
+ toolCalls,
286
+ stopReason: 'end_turn',
287
+ model: params.model,
288
+ },
256
289
  };
257
290
  }
258
291
  finally {
@@ -71,10 +71,17 @@ function extractResult(data) {
71
71
  const content0 = candidates[0]?.content || {};
72
72
  const parts = content0.parts || [];
73
73
  let content = '';
74
+ let reasoning = '';
74
75
  const toolCalls = [];
75
76
  for (const part of parts) {
76
- if (typeof part.text === 'string')
77
- content += part.text;
77
+ // Thinking models (Gemini 2.5/3 family) mark reasoning parts with
78
+ // thought: true — surface them as reasoning, never as answer text.
79
+ if (typeof part.text === 'string') {
80
+ if (part.thought === true)
81
+ reasoning += part.text;
82
+ else
83
+ content += part.text;
84
+ }
78
85
  if (part.functionCall) {
79
86
  const raw = part.functionCall;
80
87
  const fcs = Array.isArray(raw) ? raw : [raw];
@@ -87,7 +94,7 @@ function extractResult(data) {
87
94
  }
88
95
  }
89
96
  }
90
- return { content, toolCalls };
97
+ return { content, reasoning, toolCalls };
91
98
  }
92
99
  class GeminiProvider {
93
100
  id = 'gemini';
@@ -151,9 +158,10 @@ class GeminiProvider {
151
158
  throw new provider_1.ProviderError(`Gemini HTTP ${res.status}: ${text.slice(0, 300)}`, res.status, res.status >= 500);
152
159
  }
153
160
  const data = JSON.parse(text);
154
- const { content, toolCalls } = extractResult(data);
161
+ const { content, reasoning, toolCalls } = extractResult(data);
155
162
  return {
156
163
  content,
164
+ reasoning: reasoning || undefined,
157
165
  toolCalls,
158
166
  stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
159
167
  model: params.model,
@@ -169,6 +177,7 @@ class GeminiProvider {
169
177
  const decoder = new TextDecoder();
170
178
  let buffer = '';
171
179
  let content = '';
180
+ let reasoning = '';
172
181
  const toolCalls = [];
173
182
  for (;;) {
174
183
  const { done, value } = await reader.read();
@@ -187,7 +196,11 @@ class GeminiProvider {
187
196
  const json = safeJson(payload);
188
197
  if (!json)
189
198
  continue;
190
- const { content: deltaText, toolCalls: newCalls } = extractResult(json);
199
+ const { content: deltaText, reasoning: deltaThought, toolCalls: newCalls } = extractResult(json);
200
+ if (deltaThought) {
201
+ reasoning += deltaThought;
202
+ yield { type: 'delta', reasoning: deltaThought };
203
+ }
191
204
  if (deltaText) {
192
205
  content += deltaText;
193
206
  yield { type: 'delta', delta: deltaText };
@@ -202,6 +215,7 @@ class GeminiProvider {
202
215
  type: 'done',
203
216
  result: {
204
217
  content,
218
+ reasoning: reasoning || undefined,
205
219
  toolCalls,
206
220
  stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
207
221
  model: params.model,
@@ -94,8 +94,12 @@ class OllamaProvider {
94
94
  arguments: typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {}),
95
95
  };
96
96
  });
97
+ // Thinking models (Ollama ≥0.9: DeepSeek-R1, Qwen3 …) return the
98
+ // reasoning in message.thinking when the model supports it.
99
+ const thinking = typeof message.thinking === 'string' ? message.thinking : '';
97
100
  return {
98
101
  content: String(message.content || ''),
102
+ reasoning: thinking || undefined,
99
103
  toolCalls,
100
104
  stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
101
105
  model: String(data.model || params.model),
@@ -118,6 +122,7 @@ class OllamaProvider {
118
122
  const decoder = new TextDecoder();
119
123
  let buffer = '';
120
124
  let content = '';
125
+ let reasoning = '';
121
126
  const toolCalls = [];
122
127
  for (;;) {
123
128
  const { done, value } = await reader.read();
@@ -138,6 +143,7 @@ class OllamaProvider {
138
143
  type: 'done',
139
144
  result: {
140
145
  content,
146
+ reasoning: reasoning || undefined,
141
147
  toolCalls,
142
148
  stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
143
149
  model: params.model,
@@ -146,6 +152,12 @@ class OllamaProvider {
146
152
  return;
147
153
  }
148
154
  const message = (json.message || {});
155
+ // Thinking models stream reasoning in message.thinking (Ollama ≥0.9,
156
+ // DeepSeek-R1/Qwen3) — surface it so the • Thinking card renders.
157
+ if (typeof message.thinking === 'string' && message.thinking) {
158
+ reasoning += message.thinking;
159
+ yield { type: 'delta', reasoning: message.thinking };
160
+ }
149
161
  if (typeof message.content === 'string' && message.content) {
150
162
  content += message.content;
151
163
  yield { type: 'delta', delta: message.content };
@@ -165,7 +177,7 @@ class OllamaProvider {
165
177
  }
166
178
  yield {
167
179
  type: 'done',
168
- result: { content, toolCalls, stopReason: 'stop', model: params.model },
180
+ result: { content, reasoning: reasoning || undefined, toolCalls, stopReason: 'stop', model: params.model },
169
181
  };
170
182
  }
171
183
  async models() {
@@ -2,12 +2,69 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OpenAICompatProvider = void 0;
4
4
  exports.stripThinkTags = stripThinkTags;
5
+ exports.createThinkTagStreamSplitter = createThinkTagStreamSplitter;
5
6
  /**
6
7
  * OpenAI-compatible provider — used by OpenAI, OpenRouter and Custom.
7
8
  * Speaks /v1/chat/completions (and /models) with tool calling + streaming.
8
9
  */
9
10
  const provider_1 = require("./provider");
10
11
  const credentials_1 = require("../config/credentials");
12
+ /**
13
+ * Stream delta-collector for tool calls.
14
+ *
15
+ * WHY A MAP, NOT AN ARRAY INDEXED BY `part.index`:
16
+ * OpenAI spec says tool_calls deltas carry a 0-based `index` per tool call,
17
+ * and arguments arrive in fragments. Most gateways comply. But some gateways
18
+ * proxy Anthropic models and leak the UPSTREAM content-block positions as
19
+ * `index` (thinking=0, text=1, tool_use=2, ...) — with one tool call the
20
+ * chunks arrive with `index:2` (observed live on api.justwoker.icu, 2026-09-04).
21
+ * Indexing a plain array by that value produces a SPARSE array
22
+ * ([<hole>, <hole>, call]) which silently breaks `for..of` iteration in the
23
+ * agent loop (holes are skipped) and corrupts the follow-up request history.
24
+ * Collect per index in a Map, emit a DENSE array in arrival order, deduped
25
+ * by id (Anthropic ids are unique per call; OpenAI reuses one id across
26
+ * argument fragments).
27
+ */
28
+ class StreamToolCallCollector {
29
+ byIndex = new Map();
30
+ /** Feed one delta.tool_calls part; returns the latest complete call, if any. */
31
+ add(part) {
32
+ const idx = Number(part.index || 0);
33
+ const fn = (part.function || {});
34
+ const existing = this.byIndex.get(idx);
35
+ if (!existing) {
36
+ this.byIndex.set(idx, {
37
+ id: String(part.id || `call_${idx}_${Math.random().toString(36).slice(2)}`),
38
+ name: String(fn.name || ''),
39
+ arguments: String(fn.arguments || ''),
40
+ });
41
+ }
42
+ else {
43
+ if (part.id)
44
+ existing.id = String(part.id);
45
+ if (fn.name)
46
+ existing.name = String(fn.name);
47
+ if (fn.arguments)
48
+ existing.arguments += String(fn.arguments);
49
+ }
50
+ const finished = this.finished();
51
+ return finished.length > 0 ? finished[finished.length - 1] : null;
52
+ }
53
+ /** All complete calls so far, in arrival order, DENSE (no holes/nulls). */
54
+ finished() {
55
+ const seen = new Set();
56
+ const out = [];
57
+ for (const call of this.byIndex.values()) {
58
+ if (!call.name)
59
+ continue; // incomplete: no function name yet
60
+ if (seen.has(call.id))
61
+ continue; // fragment of an already-collected call
62
+ seen.add(call.id);
63
+ out.push({ id: call.id, name: call.name, arguments: call.arguments || '{}' });
64
+ }
65
+ return out;
66
+ }
67
+ }
11
68
  function toOpenAIMessages(messages) {
12
69
  const out = [];
13
70
  for (const m of messages) {
@@ -59,6 +116,81 @@ function extractReasoning(msg) {
59
116
  function stripThinkTags(text) {
60
117
  return text.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/^\s*<think>[\s\S]*$/, '').trim();
61
118
  }
119
+ /**
120
+ * Incremental `<think>…</think>` tag splitter for STREAMING: OpenAI-compat
121
+ * gateways often proxy reasoning models (DeepSeek-R1, QwQ, Qwen3 …) that
122
+ * inline their thinking into `delta.content` instead of a separate
123
+ * `reasoning_content` field. Non-streaming already strips via
124
+ * stripThinkTags — the streaming path needs the same treatment or the
125
+ * • Thinking card never appears for those models (raw <think> text used to
126
+ * leak into the visible answer too).
127
+ */
128
+ function createThinkTagStreamSplitter() {
129
+ let inside = false;
130
+ let tail = ''; // partial tag spanning chunk boundaries
131
+ const flushTail = (emit) => {
132
+ // Find the highest safe split point: a '<' that MIGHT begin a tag when
133
+ // more text arrives is kept buffered; everything before it is emitted.
134
+ const lt = tail.indexOf('<');
135
+ if (lt === -1) {
136
+ emit(tail, inside);
137
+ tail = '';
138
+ return;
139
+ }
140
+ if (lt > 0) {
141
+ emit(tail.slice(0, lt), inside);
142
+ tail = tail.slice(lt);
143
+ }
144
+ // tail now starts with '<' — wait for more data.
145
+ };
146
+ return {
147
+ /** Feed one content delta; returns {content, reasoning} pieces. */
148
+ push(chunk) {
149
+ const content = [];
150
+ const reasoning = [];
151
+ const emit = (s, isReasoning) => {
152
+ if (s)
153
+ (isReasoning ? reasoning : content).push(s);
154
+ };
155
+ tail += chunk;
156
+ for (;;) {
157
+ if (!inside) {
158
+ const open = tail.indexOf('<think>');
159
+ if (open !== -1) {
160
+ emit(tail.slice(0, open), false);
161
+ tail = tail.slice(open + 7);
162
+ inside = true;
163
+ continue;
164
+ }
165
+ flushTail(emit);
166
+ break;
167
+ }
168
+ else {
169
+ const close = tail.indexOf('</think>');
170
+ if (close !== -1) {
171
+ emit(tail.slice(0, close), true);
172
+ tail = tail.slice(close + 8);
173
+ inside = false;
174
+ continue;
175
+ }
176
+ flushTail(emit);
177
+ break;
178
+ }
179
+ }
180
+ return { content, reasoning };
181
+ },
182
+ /** End of stream: flush any buffered remainder (an unterminated
183
+ * <think> block counts as reasoning). */
184
+ finish() {
185
+ const content = [];
186
+ const reasoning = [];
187
+ if (tail)
188
+ (inside ? reasoning : content).push(tail);
189
+ tail = '';
190
+ return { content, reasoning };
191
+ },
192
+ };
193
+ }
62
194
  function parseToolCalls(raw) {
63
195
  const calls = [];
64
196
  if (!Array.isArray(raw))
@@ -232,7 +364,14 @@ class OpenAICompatProvider {
232
364
  let buffer = '';
233
365
  let content = '';
234
366
  let reasoning = '';
235
- const toolCalls = [];
367
+ // Dense collector — see StreamToolCallCollector: some gateways send
368
+ // non-0-based tool_calls indices (Anthropic block positions), which
369
+ // used to produce a sparse toolCalls array the agent loop skipped.
370
+ const toolCollector = new StreamToolCallCollector();
371
+ // Reasoning models behind OpenAI-compat gateways may inline thinking
372
+ // into delta.content as <think>…</think> (DeepSeek-R1/QwQ/Qwen3).
373
+ // Split it out so the • Thinking card works on EVERY model.
374
+ const thinkSplit = createThinkTagStreamSplitter();
236
375
  for (;;) {
237
376
  const { done, value } = await reader.read();
238
377
  if (done)
@@ -246,13 +385,20 @@ class OpenAICompatProvider {
246
385
  continue;
247
386
  const payload = trimmed.slice(5).trim();
248
387
  if (payload === '[DONE]') {
388
+ // Flush any <think> splitter remainder before finishing.
389
+ const tail = thinkSplit.finish();
390
+ for (const r of tail.reasoning)
391
+ reasoning += r;
392
+ for (const c of tail.content)
393
+ content += c;
394
+ const calls = toolCollector.finished();
249
395
  yield {
250
396
  type: 'done',
251
397
  result: {
252
398
  content,
253
399
  reasoning: reasoning || undefined,
254
- toolCalls,
255
- stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
400
+ toolCalls: calls,
401
+ stopReason: calls.length > 0 ? 'tool_calls' : 'stop',
256
402
  model: params.model,
257
403
  },
258
404
  };
@@ -276,48 +422,42 @@ class OpenAICompatProvider {
276
422
  yield { type: 'delta', reasoning: rc };
277
423
  }
278
424
  if (typeof delta.content === 'string' && delta.content) {
279
- content += delta.content;
280
- yield { type: 'delta', delta: delta.content };
425
+ const pieces = thinkSplit.push(delta.content);
426
+ for (const r of pieces.reasoning) {
427
+ reasoning += r;
428
+ yield { type: 'delta', reasoning: r };
429
+ }
430
+ for (const c of pieces.content) {
431
+ content += c;
432
+ yield { type: 'delta', delta: c };
433
+ }
281
434
  }
282
435
  const tc = delta.tool_calls;
283
436
  if (Array.isArray(tc)) {
437
+ let latest = null;
284
438
  for (const part of tc) {
285
- const idx = Number(part.index || 0);
286
- const fn = (part.function || {});
287
- const existing = toolCalls[idx];
288
- if (!existing) {
289
- toolCalls[idx] = {
290
- id: String(part.id || `call_${idx}_${Math.random().toString(36).slice(2)}`),
291
- name: String(fn.name || ''),
292
- arguments: String(fn.arguments || ''),
293
- };
294
- }
295
- else {
296
- if (part.id)
297
- existing.id = String(part.id);
298
- if (fn.name)
299
- existing.name = String(fn.name);
300
- if (fn.arguments)
301
- existing.arguments += String(fn.arguments);
302
- }
439
+ const call = toolCollector.add(part);
440
+ if (call)
441
+ latest = call;
303
442
  }
304
- const finished = toolCalls.filter((t) => t.name).map((t) => ({
305
- ...t,
306
- arguments: t.arguments || '{}',
307
- }));
308
- if (finished.length > 0) {
309
- yield { type: 'tool_call', toolCall: finished[finished.length - 1] };
443
+ if (latest) {
444
+ yield { type: 'tool_call', toolCall: latest };
310
445
  }
311
446
  }
312
447
  }
313
448
  }
314
- // Stream ended without [DONE]
449
+ // Stream ended without [DONE] — flush the <think> splitter tail.
450
+ const tail = thinkSplit.finish();
451
+ for (const r of tail.reasoning)
452
+ reasoning += r;
453
+ for (const c of tail.content)
454
+ content += c;
315
455
  yield {
316
456
  type: 'done',
317
457
  result: {
318
458
  content,
319
459
  reasoning: reasoning || undefined,
320
- toolCalls: toolCalls.filter((t) => t.name),
460
+ toolCalls: toolCollector.finished(),
321
461
  stopReason: 'stop',
322
462
  model: params.model,
323
463
  },
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.classifyCommand = classifyCommand;
4
+ const sensitive_files_1 = require("./sensitive-files");
4
5
  const BLOCKED_PATTERNS = [
5
6
  /(^|[;&|]\s*)\s*rm\s+(-[a-z]*[rf][a-z]*\s+)*\/\s*($|[^a-zA-Z])/,
6
7
  /(^|[;&|]\s*)\s*rm\s+(-[a-z]*[rf][a-z]*\s+)*~(\/|$)/,
@@ -59,6 +60,12 @@ const ASK_PATTERNS = [
59
60
  /** Commands that are safe to run without asking. */
60
61
  const SAFE_PATTERNS = [
61
62
  /(^|[;&|]\s*)\s*(ls|cat|head|tail|less|more|wc|grep|find|locate|which|type|file|stat|du|df|tree)\s/,
63
+ // Local dev-server plumbing (compound verification commands like
64
+ // `cd web && python3 -m http.server 8917 & sleep 1; curl localhost; kill %1`)
65
+ /(^|[;&|]\s*)\s*cd\s+\S+/,
66
+ /(^|[;&|]\s*)\s*sleep\s+\d/,
67
+ /(^|[;&|]\s*)\s*python3?\s+-m\s+http\.server\b/,
68
+ /(^|[;&|]\s*)\s*kill\s+%\d+/,
62
69
  /(^|[;&|]\s*)\s*echo\s/,
63
70
  /(^|[;&|]\s*)\s*node\s+(-v|--version)/,
64
71
  /(^|[;&|]\s*)\s*npm\s+(-v|--version|ls|list)\s*/,
@@ -78,11 +85,63 @@ function classifyCommand(command) {
78
85
  return { level: 'blocked', reason: `command matches blocked pattern: ${re.source}` };
79
86
  }
80
87
  }
88
+ // Sensitive-file escalation (Freebuff fileFilter spirit): a shell command
89
+ // that references a secret (.env, private keys, credentials.json, …) must
90
+ // be PROMPTED even in yolo mode — `cat .env` / `cp id_rsa /tmp` / piping
91
+ // secrets into the network is the classic exfiltration path around the
92
+ // fs-tool guard. Only `cat *.example` template references stay exempt so
93
+ // agents can still learn the config surface.
94
+ const tokens = command.split(/\s+/).map((t) => t.replace(/['"`]/g, ''));
95
+ const touchesSensitive = tokens.some((raw) => {
96
+ // curl-style --data=@file and @file forms: keep only the path part.
97
+ const t = raw.split('=').pop() ?? raw;
98
+ const base = (t.startsWith('@') ? t.slice(1) : t).split('/').pop() ?? '';
99
+ if (!base)
100
+ return false;
101
+ if (base.endsWith('.example') || base.endsWith('.sample'))
102
+ return false;
103
+ return (0, sensitive_files_1.isSensitiveFile)(base) || (0, sensitive_files_1.isSensitiveEnvFilePath)(base);
104
+ });
105
+ if (touchesSensitive) {
106
+ return { level: 'dangerous', reason: 'command references a sensitive file (secrets must be prompted even in yolo)' };
107
+ }
81
108
  for (const re of DANGEROUS_PATTERNS) {
82
109
  if (re.test(command)) {
83
110
  return { level: 'dangerous', reason: `command matches dangerous pattern: ${re.source}` };
84
111
  }
85
112
  }
113
+ // Loopback exemption (Freebuff-style smooth local dev): curl/wget whose
114
+ // EVERY network target is localhost/127.0.0.1 cannot exfiltrate anything
115
+ // off the machine, so testing a local dev server should not prompt. Runs
116
+ // AFTER the dangerous/sensitive checks so `curl | bash` and
117
+ // `curl --data @secrets http://localhost` still escalate. Any remote host
118
+ // in the same command (or an extra URL) falls through to the ASK pattern.
119
+ if (/(^|[;&|]\s*)\s*(curl|wget)\s/.test(command)) {
120
+ const targets = [];
121
+ for (const seg of command.split(/[;&|]+/)) {
122
+ const m = seg.trim().match(/^(curl|wget)\s+(.*)$/);
123
+ if (!m)
124
+ continue;
125
+ for (const raw of m[2].split(/\s+/)) {
126
+ const t = raw.replace(/['"`]/g, '').split('=').pop() ?? '';
127
+ if (t.startsWith('-'))
128
+ continue;
129
+ // URL/host-shaped token: scheme://host, host:port, or host.tld
130
+ if (/^https?:\/\//i.test(t) || (/^[\w.\-\[%]+(:\d+)?(\/|$|\?)/.test(t) && /[.:\[%]|localhost/.test(t))) {
131
+ targets.push(t);
132
+ }
133
+ }
134
+ }
135
+ const isLoopback = (tok) => {
136
+ let s = tok.replace(/^https?:\/\//i, '').toLowerCase();
137
+ s = (s.split('/')[0] ?? '').split(':')[0] ?? '';
138
+ return (s === 'localhost' || s === '127.0.0.1' || s === '::1' ||
139
+ s === '[::1]' || s === '[::]' || s.endsWith('.localhost'));
140
+ };
141
+ if (targets.length > 0 && targets.every(isLoopback)) {
142
+ return { level: 'safe', reason: 'curl/wget targets loopback only (local dev server test, no remote transfer)' };
143
+ }
144
+ }
86
145
  for (const re of ASK_PATTERNS) {
87
146
  if (re.test(command)) {
88
147
  return { level: 'ask', reason: `command matches ask pattern: ${re.source}` };
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isSensitiveEnvFilePath = isSensitiveEnvFilePath;
37
+ exports.isSensitiveFile = isSensitiveFile;
38
+ exports.sensitiveFileGuard = sensitiveFileGuard;
39
+ /**
40
+ * Sensitive-file guard — port of Freebuff's fileFilter
41
+ * (CodebuffAI/freebuff cli/src/utils/create-run-config.ts: isSensitiveFile +
42
+ * SENSITIVE_EXTENSIONS/BASENAMES/PATTERNS, plus @codebuff/common's
43
+ * isSensitiveEnvFilePath).
44
+ *
45
+ * Freebuff applies this as a hard `fileFilter` on every SDK file operation:
46
+ * reads AND writes of secrets (.env, private keys, credentials, kubeconfig…)
47
+ * are blocked outright regardless of yolo/ask mode — the model never sees
48
+ * their contents. This module gives vector-agent the same layer: the fs tools
49
+ * call `sensitiveFileGuard()` before touching a path, and shell commands that
50
+ * reference a sensitive basename are escalated to 'dangerous' by the command
51
+ * policy (prompted even in yolo mode).
52
+ *
53
+ * Escape hatch for power users: VECTOR_ALLOW_SENSITIVE=1 disables the guard
54
+ * (documented in HANDOFF.md — off by default, like Freebuff).
55
+ */
56
+ const fs = __importStar(require("node:fs"));
57
+ const path = __importStar(require("node:path"));
58
+ /** File extensions that are always secrets (Freebuff SENSITIVE_EXTENSIONS). */
59
+ const SENSITIVE_EXTENSIONS = new Set([
60
+ '.pem', '.key', '.p12', '.pfx', '.jks', '.keystore', '.crt', '.cer',
61
+ ]);
62
+ /** Exact basenames that are always secrets (Freebuff SENSITIVE_BASENAMES,
63
+ * plus credentials.json — vector-agent's own key store). */
64
+ const SENSITIVE_BASENAMES = new Set([
65
+ '.htpasswd', '.netrc', 'credentials', 'credentials.json',
66
+ '.npmrc', '.yarnrc', '.yarnrc.yml', 'auth.json', '.pypirc',
67
+ 'terraform.tfvars', '.terraformrc', '.vector-credentials',
68
+ ]);
69
+ /** SSH / private-key prefixes (Freebuff SENSITIVE_PATTERNS.prefix; the .pub
70
+ * counterpart is explicitly allowed). */
71
+ const SENSITIVE_KEY_PREFIXES = ['id_rsa', 'id_ed25519', 'id_dsa', 'id_ecdsa'];
72
+ /** Substring hits (Freebuff SENSITIVE_PATTERNS.substring). */
73
+ const SENSITIVE_SUBSTRINGS = ['kubeconfig', '.tfstate'];
74
+ /** Suffixes that mark a TEMPLATE — safe to read so agents can learn which
75
+ * env vars exist (.env.example etc). Not in Freebuff, but our listing tools
76
+ * already surface them as visible files. */
77
+ const TEMPLATE_SUFFIXES = ['.example', '.sample', '.template'];
78
+ /** Port of @codebuff/common isSensitiveEnvFilePath: basename is `.env` or a
79
+ * `.env.<anything>` variant (.env.local, .env.production, …), or `.envrc`. */
80
+ function isSensitiveEnvFilePath(filePath) {
81
+ const base = path.basename(filePath).toLowerCase();
82
+ return base === '.env' || base === '.envrc' || base.startsWith('.env.');
83
+ }
84
+ /** Check if a file is sensitive and must be blocked from tool access. */
85
+ function isSensitiveFile(filePath) {
86
+ const base = path.basename(filePath);
87
+ const lower = base.toLowerCase();
88
+ const ext = path.extname(filePath).toLowerCase();
89
+ if (TEMPLATE_SUFFIXES.some((s) => lower.endsWith(s)))
90
+ return false;
91
+ if (isSensitiveEnvFilePath(filePath))
92
+ return true;
93
+ if (SENSITIVE_EXTENSIONS.has(ext))
94
+ return true;
95
+ if (SENSITIVE_BASENAMES.has(lower))
96
+ return true;
97
+ if (SENSITIVE_KEY_PREFIXES.some((p) => lower.startsWith(p)) &&
98
+ !lower.endsWith('.pub')) {
99
+ return true;
100
+ }
101
+ if (SENSITIVE_SUBSTRINGS.some((s) => lower.includes(s)))
102
+ return true;
103
+ return false;
104
+ }
105
+ /**
106
+ * Guard for fs tools: returns an ERROR output string when the path (lexical
107
+ * or, when it exists on disk, its real path — catches symlink + `../`
108
+ * escapes) is sensitive, null when the operation may proceed.
109
+ * VECTOR_ALLOW_SENSITIVE=1 disables the guard entirely.
110
+ */
111
+ function sensitiveFileGuard(absPath, displayPath) {
112
+ if (process.env.VECTOR_ALLOW_SENSITIVE === '1')
113
+ return null;
114
+ const check = (p) => {
115
+ try {
116
+ return isSensitiveFile(p) || isSensitiveFile(fs.realpathSync(p));
117
+ }
118
+ catch {
119
+ return isSensitiveFile(p);
120
+ }
121
+ };
122
+ if (check(absPath)) {
123
+ return `ERROR: blocked "${displayPath}" — sensitive file (secrets are never read/written; Freebuff fileFilter port). Use VECTOR_ALLOW_SENSITIVE=1 to override.`;
124
+ }
125
+ return null;
126
+ }