@ruvnet/open-claude-code 2.0.1 → 2.1.1-nightly.20261011.202

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/package.json +29 -4
  4. package/src/auth/oauth.mjs +46 -6
  5. package/src/config/cli-args.mjs +20 -0
  6. package/src/config/env.mjs +2 -2
  7. package/src/config/settings.mjs +30 -130
  8. package/src/core/agent-loop.mjs +92 -28
  9. package/src/core/providers.mjs +19 -1
  10. package/src/core/session.mjs +34 -149
  11. package/src/core/system-prompt.mjs +43 -47
  12. package/src/hooks/engine.mjs +10 -2
  13. package/src/index.mjs +60 -32
  14. package/src/optimize/cascade.mjs +128 -0
  15. package/src/optimize/commands.mjs +139 -0
  16. package/src/optimize/router.mjs +212 -0
  17. package/src/optimize/store.mjs +170 -0
  18. package/src/permissions/checker.mjs +8 -79
  19. package/src/permissions/injection-check.mjs +3 -2
  20. package/src/permissions/path-check.mjs +8 -0
  21. package/src/permissions/sandbox.mjs +8 -2
  22. package/src/permissions/ssrf-check.mjs +102 -0
  23. package/src/plugins/loader.mjs +14 -6
  24. package/src/skills/loader.mjs +16 -4
  25. package/src/tools/bash.mjs +3 -40
  26. package/src/tools/enter-worktree.mjs +5 -3
  27. package/src/tools/exit-worktree.mjs +4 -3
  28. package/src/tools/grep.mjs +26 -11
  29. package/src/tools/lsp.mjs +23 -9
  30. package/src/tools/read.mjs +20 -9
  31. package/src/tools/send-message.mjs +5 -1
  32. package/src/tools/web-fetch.mjs +15 -1
  33. package/src/tools/web-search.mjs +13 -1
  34. package/src/ui/app.mjs +286 -0
  35. package/src/ui/commands.mjs +121 -71
  36. package/src/ui/components.mjs +219 -0
  37. package/src/ui/ink-app.mjs +13 -52
  38. package/src/ui/markdown.mjs +278 -0
  39. package/src/ui/repl.mjs +10 -25
  40. package/test/test.mjs +0 -2657
@@ -5,6 +5,8 @@
5
5
  * Messages are stored in a shared message queue.
6
6
  */
7
7
 
8
+ import { randomBytes } from 'crypto';
9
+
8
10
  const messageQueue = new Map(); // agentId -> messages[]
9
11
 
10
12
  export const SendMessageTool = {
@@ -38,8 +40,10 @@ export const SendMessageTool = {
38
40
  },
39
41
 
40
42
  async call(input) {
43
+ // Use cryptographically secure random bytes for message IDs to
44
+ // prevent guessing/enumeration of message identifiers.
41
45
  const msg = {
42
- id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
46
+ id: `msg_${Date.now()}_${randomBytes(6).toString('hex')}`,
43
47
  from: process.env.AGENT_ID || 'main',
44
48
  to: input.to,
45
49
  type: input.type || 'notification',
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Web Fetch Tool — fetch URL content using built-in Node.js fetch.
3
+ *
4
+ * Security: validates the URL before fetching to prevent SSRF attacks.
5
+ * Private/link-local/loopback addresses and cloud metadata endpoints are
6
+ * blocked so the tool cannot be used to probe internal network services.
3
7
  */
4
8
 
9
+ import { isPrivateHost } from '../permissions/ssrf-check.mjs';
10
+
5
11
  export const WebFetchTool = {
6
12
  name: 'WebFetch',
7
13
  description: 'Fetch content from a URL. Returns the response body as text.',
@@ -25,7 +31,15 @@ export const WebFetchTool = {
25
31
  const errors = [];
26
32
  if (!input.url) errors.push('url is required');
27
33
  try {
28
- new URL(input.url);
34
+ const parsed = new URL(input.url);
35
+ // Only allow http and https protocols
36
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
37
+ errors.push('url must use http or https protocol');
38
+ }
39
+ // Block SSRF: reject private/internal/loopback hosts
40
+ if (isPrivateHost(parsed.hostname)) {
41
+ errors.push('url must not target private or internal network addresses');
42
+ }
29
43
  } catch {
30
44
  errors.push('url must be a valid URL');
31
45
  }
@@ -5,7 +5,11 @@
5
5
  * - Brave Search API (BRAVE_API_KEY)
6
6
  * - SearXNG (self-hosted, SEARXNG_URL)
7
7
  * - Fallback: returns instructions to use web-fetch instead
8
+ *
9
+ * Security: validates that SEARXNG_URL does not target private/internal
10
+ * addresses (SSRF guard) before making requests.
8
11
  */
12
+ import { isPrivateHost } from '../permissions/ssrf-check.mjs';
9
13
 
10
14
  export const WebSearchTool = {
11
15
  name: 'WebSearch',
@@ -32,9 +36,17 @@ export const WebSearchTool = {
32
36
  return searchBrave(input.query, limit, braveKey);
33
37
  }
34
38
 
35
- // Try SearXNG
39
+ // Try SearXNG — validate the configured URL is not an internal host
36
40
  const searxUrl = process.env.SEARXNG_URL;
37
41
  if (searxUrl) {
42
+ try {
43
+ const parsed = new URL(searxUrl);
44
+ if (isPrivateHost(parsed.hostname)) {
45
+ return 'SEARXNG_URL points to a private/internal address. Configure a public SearXNG instance.';
46
+ }
47
+ } catch {
48
+ return 'SEARXNG_URL is not a valid URL.';
49
+ }
38
50
  return searchSearxng(input.query, limit, searxUrl);
39
51
  }
40
52
 
package/src/ui/app.mjs ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Main Ink App — full React terminal UI matching Claude Code's interface.
3
+ *
4
+ * Uses React.createElement (no JSX transpiler needed).
5
+ *
6
+ * Features:
7
+ * - Scrolling messages area with assistant, user, tool, thinking messages
8
+ * - Persistent status bar (model, context%, cost, time, permission mode)
9
+ * - Text input with prompt indicator
10
+ * - Loading spinner during API calls
11
+ * - Tool execution display with spinners
12
+ * - Keyboard shortcuts (Escape, Ctrl+C, Ctrl+L)
13
+ * - Slash command support (39 commands)
14
+ * - Skill invocation via /skill-name
15
+ */
16
+
17
+ import React, { useState, useCallback, useRef } from 'react';
18
+ import { render, Box, Text, useInput, useApp } from 'ink';
19
+ import TextInput from 'ink-text-input';
20
+ import {
21
+ StatusBar,
22
+ Message,
23
+ LoadingIndicator,
24
+ WelcomeBanner,
25
+ } from './components.mjs';
26
+ import { executeCommand, COMMANDS } from './commands.mjs';
27
+
28
+ const h = React.createElement;
29
+
30
+ /**
31
+ * Main application component.
32
+ */
33
+ export function App({ agentLoop, settings }) {
34
+ const [input, setInput] = useState('');
35
+ const [messages, setMessages] = useState([]);
36
+ const [isLoading, setIsLoading] = useState(false);
37
+ const [model, setModel] = useState(agentLoop.state.model || 'claude-sonnet-4-6');
38
+ const [tokenCount, setTokenCount] = useState({ input: 0, output: 0 });
39
+ const [cost, setCost] = useState(0);
40
+ const [mode] = useState(settings.permissions?.defaultMode || 'default');
41
+ const [toolStatus, setToolStatus] = useState(null);
42
+ const [startTime] = useState(Date.now());
43
+ const loadingRef = useRef(false);
44
+ const { exit } = useApp();
45
+
46
+ // Calculate cost from token counts
47
+ const updateCost = useCallback((tokens) => {
48
+ const m = agentLoop.state.model || '';
49
+ let priceIn = 3, priceOut = 15;
50
+ if (m.includes('haiku')) { priceIn = 0.25; priceOut = 1.25; }
51
+ if (m.includes('opus')) { priceIn = 15; priceOut = 75; }
52
+ const costIn = (tokens.input / 1_000_000) * priceIn;
53
+ const costOut = (tokens.output / 1_000_000) * priceOut;
54
+ setCost(costIn + costOut);
55
+ }, [agentLoop.state.model]);
56
+
57
+ // Add a message to the display
58
+ const addMessage = useCallback((msg) => {
59
+ setMessages(prev => {
60
+ const next = [...prev, msg];
61
+ return next.length > 200 ? next.slice(-150) : next;
62
+ });
63
+ }, []);
64
+
65
+ // Handle slash commands
66
+ const handleCommand = useCallback((text) => {
67
+ const cmdName = text.split(/\s+/)[0].toLowerCase();
68
+
69
+ // Check for skill invocation
70
+ if (!COMMANDS[cmdName] && agentLoop.state._skillsLoader) {
71
+ const skill = agentLoop.state._skillsLoader.get(cmdName.slice(1));
72
+ if (skill) {
73
+ const args = text.slice(cmdName.length).trim();
74
+ addMessage({ role: 'user', content: text });
75
+ runPrompt(
76
+ `[Skill: ${skill.name}]\n${skill.prompt}${args ? `\nArguments: ${args}` : ''}`
77
+ );
78
+ return;
79
+ }
80
+ }
81
+
82
+ const { response, exit: shouldExit } = executeCommand(text, agentLoop.state);
83
+ if (shouldExit) {
84
+ exit();
85
+ return;
86
+ }
87
+ addMessage({ role: 'system', content: response });
88
+
89
+ // Sync model if it changed
90
+ if (agentLoop.state.model !== model) {
91
+ setModel(agentLoop.state.model);
92
+ }
93
+ }, [agentLoop, model, addMessage, exit]);
94
+
95
+ // Run a prompt through the agent loop
96
+ const runPrompt = useCallback(async (text) => {
97
+ setIsLoading(true);
98
+ loadingRef.current = true;
99
+ setToolStatus(null);
100
+
101
+ try {
102
+ for await (const event of agentLoop.run(text)) {
103
+ switch (event.type) {
104
+ case 'stream_event':
105
+ setMessages(prev => {
106
+ const last = prev[prev.length - 1];
107
+ if (last && last.role === 'assistant' && last._streaming) {
108
+ return [
109
+ ...prev.slice(0, -1),
110
+ { ...last, content: (last.content || '') + (event.text || '') },
111
+ ];
112
+ }
113
+ return [
114
+ ...prev,
115
+ { role: 'assistant', content: event.text || '', _streaming: true },
116
+ ];
117
+ });
118
+ break;
119
+
120
+ case 'assistant':
121
+ setMessages(prev => {
122
+ const last = prev[prev.length - 1];
123
+ if (last && last.role === 'assistant' && last._streaming) {
124
+ return [
125
+ ...prev.slice(0, -1),
126
+ { ...last, _streaming: false },
127
+ ];
128
+ }
129
+ if (event.content) {
130
+ return [...prev, { role: 'assistant', content: event.content }];
131
+ }
132
+ return prev;
133
+ });
134
+ break;
135
+
136
+ case 'thinking':
137
+ addMessage({ role: 'thinking', thinking: event.text });
138
+ break;
139
+
140
+ case 'tool_progress':
141
+ setToolStatus(event.tool);
142
+ addMessage({
143
+ role: 'tool',
144
+ toolName: event.tool,
145
+ toolStatus: 'running',
146
+ });
147
+ break;
148
+
149
+ case 'result':
150
+ setToolStatus(null);
151
+ setMessages(prev => {
152
+ const idx = prev.findLastIndex(
153
+ m => m.role === 'tool' &&
154
+ m.toolName === event.tool &&
155
+ m.toolStatus === 'running'
156
+ );
157
+ if (idx >= 0) {
158
+ const updated = [...prev];
159
+ updated[idx] = {
160
+ ...updated[idx],
161
+ toolResult: typeof event.result === 'string'
162
+ ? event.result
163
+ : JSON.stringify(event.result),
164
+ toolStatus: 'done',
165
+ };
166
+ return updated;
167
+ }
168
+ return prev;
169
+ });
170
+ break;
171
+
172
+ case 'compaction':
173
+ addMessage({ role: 'system', content: `[compaction #${event.count}]` });
174
+ break;
175
+
176
+ case 'hookPermissionResult':
177
+ if (!event.allowed) {
178
+ addMessage({ role: 'system', content: `[blocked: ${event.tool}]` });
179
+ }
180
+ break;
181
+
182
+ case 'error':
183
+ addMessage({ role: 'error', content: event.message });
184
+ break;
185
+
186
+ case 'stop':
187
+ break;
188
+ }
189
+
190
+ // Update token counts
191
+ const usage = agentLoop.state.tokenUsage;
192
+ setTokenCount({ input: usage.input, output: usage.output });
193
+ updateCost(usage);
194
+ }
195
+ } catch (err) {
196
+ addMessage({ role: 'error', content: err.message });
197
+ } finally {
198
+ setIsLoading(false);
199
+ loadingRef.current = false;
200
+ setToolStatus(null);
201
+ }
202
+ }, [agentLoop, addMessage, updateCost]);
203
+
204
+ // Handle submit
205
+ const handleSubmit = useCallback((value) => {
206
+ const trimmed = value.trim();
207
+ if (!trimmed) return;
208
+
209
+ setInput('');
210
+
211
+ if (trimmed.startsWith('/')) {
212
+ handleCommand(trimmed);
213
+ return;
214
+ }
215
+
216
+ addMessage({ role: 'user', content: trimmed });
217
+ runPrompt(trimmed);
218
+ }, [handleCommand, addMessage, runPrompt]);
219
+
220
+ // Keyboard shortcuts
221
+ useInput((ch, key) => {
222
+ if (key.escape && loadingRef.current) {
223
+ setIsLoading(false);
224
+ setToolStatus(null);
225
+ addMessage({ role: 'system', content: '[cancelled]' });
226
+ }
227
+ if (key.ctrl && ch === 'c') {
228
+ exit();
229
+ }
230
+ if (key.ctrl && ch === 'l') {
231
+ setMessages([]);
232
+ }
233
+ });
234
+
235
+ const toolCount = agentLoop.state.tools?.list?.()?.length || 0;
236
+
237
+ // Build the message list
238
+ const messageElements = messages.map((msg, i) =>
239
+ h(Message, { key: i, ...msg })
240
+ );
241
+
242
+ return h(Box, { flexDirection: 'column' },
243
+ // Welcome banner
244
+ h(WelcomeBanner, { model, toolCount }),
245
+
246
+ // Messages area
247
+ h(Box, { flexDirection: 'column', flexGrow: 1 },
248
+ ...messageElements,
249
+ isLoading ? h(LoadingIndicator, { tool: toolStatus }) : null,
250
+ ),
251
+
252
+ // Input area
253
+ h(Box, { borderStyle: 'round', borderColor: 'cyan', paddingX: 1, marginTop: 1 },
254
+ h(Text, { color: 'cyan' }, '> '),
255
+ h(TextInput, {
256
+ value: input,
257
+ onChange: setInput,
258
+ onSubmit: handleSubmit,
259
+ placeholder: 'Type a message...',
260
+ }),
261
+ ),
262
+
263
+ // Status bar
264
+ h(StatusBar, {
265
+ model,
266
+ tokens: tokenCount,
267
+ cost,
268
+ mode,
269
+ startTime,
270
+ contextMax: settings.maxContextTokens || 200000,
271
+ }),
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Start the Ink application.
277
+ * @param {object} agentLoop - agent loop instance
278
+ * @param {object} settings - loaded settings
279
+ * @returns {object} Ink instance
280
+ */
281
+ export function startInkApp(agentLoop, settings) {
282
+ const instance = render(
283
+ h(App, { agentLoop, settings })
284
+ );
285
+ return instance;
286
+ }