@ruvnet/open-claude-code 2.0.0
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/package.json +27 -0
- package/src/agents/loader.mjs +89 -0
- package/src/agents/parser.mjs +98 -0
- package/src/agents/teams.mjs +123 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/config/cli-args.mjs +139 -0
- package/src/config/env.mjs +261 -0
- package/src/config/settings.mjs +119 -0
- package/src/core/agent-loop.mjs +462 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/checkpoints.mjs +141 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session.mjs +143 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +135 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/index.mjs +220 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/loader.mjs +147 -0
- package/src/skills/runner.mjs +55 -0
- package/src/telemetry/index.mjs +96 -0
- package/src/tools/agent.mjs +127 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +148 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +127 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +114 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/commands.mjs +499 -0
- package/src/ui/ink-app.mjs +231 -0
- package/src/ui/repl.mjs +144 -0
- package/test/test.mjs +1857 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE Transport — Server-Sent Events transport for MCP.
|
|
3
|
+
*
|
|
4
|
+
* Connects to an MCP server over HTTP using SSE for server-to-client
|
|
5
|
+
* messages and POST for client-to-server messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class SseTransport {
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} url - SSE endpoint URL
|
|
11
|
+
* @param {object} [options] - { headers, timeout }
|
|
12
|
+
*/
|
|
13
|
+
constructor(url, options = {}) {
|
|
14
|
+
this.url = url;
|
|
15
|
+
this.headers = options.headers || {};
|
|
16
|
+
this.timeout = options.timeout || 30000;
|
|
17
|
+
this.connected = false;
|
|
18
|
+
this.messageHandlers = [];
|
|
19
|
+
this.reader = null;
|
|
20
|
+
this.abortController = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async connect() {
|
|
24
|
+
this.abortController = new AbortController();
|
|
25
|
+
|
|
26
|
+
const res = await fetch(this.url, {
|
|
27
|
+
headers: {
|
|
28
|
+
Accept: 'text/event-stream',
|
|
29
|
+
...this.headers,
|
|
30
|
+
},
|
|
31
|
+
signal: this.abortController.signal,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
throw new Error(`SSE connect failed: HTTP ${res.status}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.connected = true;
|
|
39
|
+
this.reader = res.body.getReader();
|
|
40
|
+
this._readLoop();
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async _readLoop() {
|
|
45
|
+
const decoder = new TextDecoder();
|
|
46
|
+
let buffer = '';
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
while (this.connected) {
|
|
50
|
+
const { done, value } = await this.reader.read();
|
|
51
|
+
if (done) break;
|
|
52
|
+
|
|
53
|
+
buffer += decoder.decode(value, { stream: true });
|
|
54
|
+
const events = buffer.split('\n\n');
|
|
55
|
+
buffer = events.pop() || '';
|
|
56
|
+
|
|
57
|
+
for (const raw of events) {
|
|
58
|
+
const parsed = this._parseSSE(raw);
|
|
59
|
+
if (parsed) {
|
|
60
|
+
for (const handler of this.messageHandlers) {
|
|
61
|
+
handler(parsed);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.name !== 'AbortError') {
|
|
68
|
+
this._handleError(err);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_parseSSE(raw) {
|
|
74
|
+
let eventType = 'message';
|
|
75
|
+
const dataLines = [];
|
|
76
|
+
|
|
77
|
+
for (const line of raw.split('\n')) {
|
|
78
|
+
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
|
79
|
+
else if (line.startsWith('data: ')) dataLines.push(line.slice(6));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (dataLines.length === 0) return null;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
return {
|
|
86
|
+
type: eventType,
|
|
87
|
+
data: JSON.parse(dataLines.join('\n')),
|
|
88
|
+
};
|
|
89
|
+
} catch {
|
|
90
|
+
return { type: eventType, data: dataLines.join('\n') };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async send(message) {
|
|
95
|
+
const res = await fetch(this.url, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
...this.headers,
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify(message),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
throw new Error(`SSE send failed: HTTP ${res.status}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const text = await res.text();
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(text);
|
|
111
|
+
} catch {
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
onMessage(handler) {
|
|
117
|
+
this.messageHandlers.push(handler);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_handleError(err) {
|
|
121
|
+
if (process.env.MCP_DEBUG) {
|
|
122
|
+
console.error(`[SSE transport] Error: ${err.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async disconnect() {
|
|
127
|
+
this.connected = false;
|
|
128
|
+
this.abortController?.abort();
|
|
129
|
+
this.reader = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Transport — bidirectional MCP transport over WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* Uses the ws protocol for full-duplex communication with MCP servers.
|
|
5
|
+
* Falls back to a stub if WebSocket is not available in the runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class WebSocketTransport {
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} url - WebSocket URL (ws:// or wss://)
|
|
11
|
+
* @param {object} [options] - { headers, timeout, protocols }
|
|
12
|
+
*/
|
|
13
|
+
constructor(url, options = {}) {
|
|
14
|
+
this.url = url;
|
|
15
|
+
this.options = options;
|
|
16
|
+
this.timeout = options.timeout || 30000;
|
|
17
|
+
this.ws = null;
|
|
18
|
+
this.connected = false;
|
|
19
|
+
this.requestId = 0;
|
|
20
|
+
this.pending = new Map();
|
|
21
|
+
this.messageHandlers = [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async connect() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
try {
|
|
27
|
+
// Use global WebSocket if available (Node 22+)
|
|
28
|
+
const WS = globalThis.WebSocket;
|
|
29
|
+
if (!WS) {
|
|
30
|
+
throw new Error('WebSocket not available in this runtime');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.ws = new WS(this.url, this.options.protocols);
|
|
34
|
+
|
|
35
|
+
const timeout = setTimeout(() => {
|
|
36
|
+
reject(new Error('WebSocket connection timeout'));
|
|
37
|
+
}, this.timeout);
|
|
38
|
+
|
|
39
|
+
this.ws.onopen = () => {
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
this.connected = true;
|
|
42
|
+
resolve(this);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
this.ws.onmessage = (event) => {
|
|
46
|
+
this._handleMessage(event.data);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
this.ws.onerror = (err) => {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
reject(new Error(`WebSocket error: ${err.message || 'unknown'}`));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this.ws.onclose = () => {
|
|
55
|
+
this.connected = false;
|
|
56
|
+
for (const [, { reject: rej }] of this.pending) {
|
|
57
|
+
rej(new Error('WebSocket closed'));
|
|
58
|
+
}
|
|
59
|
+
this.pending.clear();
|
|
60
|
+
};
|
|
61
|
+
} catch (err) {
|
|
62
|
+
reject(err);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_handleMessage(data) {
|
|
68
|
+
try {
|
|
69
|
+
const msg = JSON.parse(typeof data === 'string' ? data : data.toString());
|
|
70
|
+
|
|
71
|
+
// Handle response to pending request
|
|
72
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
73
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
74
|
+
this.pending.delete(msg.id);
|
|
75
|
+
if (msg.error) {
|
|
76
|
+
reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
77
|
+
} else {
|
|
78
|
+
resolve(msg.result);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Handle notifications
|
|
84
|
+
for (const handler of this.messageHandlers) {
|
|
85
|
+
handler(msg);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Malformed message
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async request(method, params) {
|
|
93
|
+
if (!this.connected) throw new Error('WebSocket not connected');
|
|
94
|
+
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const id = ++this.requestId;
|
|
97
|
+
this.pending.set(id, { resolve, reject });
|
|
98
|
+
|
|
99
|
+
const timeout = setTimeout(() => {
|
|
100
|
+
this.pending.delete(id);
|
|
101
|
+
reject(new Error(`WebSocket request timeout: ${method}`));
|
|
102
|
+
}, this.timeout);
|
|
103
|
+
|
|
104
|
+
const origResolve = resolve;
|
|
105
|
+
this.pending.set(id, {
|
|
106
|
+
resolve: (val) => { clearTimeout(timeout); origResolve(val); },
|
|
107
|
+
reject: (err) => { clearTimeout(timeout); reject(err); },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.ws.send(JSON.stringify({
|
|
111
|
+
jsonrpc: '2.0',
|
|
112
|
+
id,
|
|
113
|
+
method,
|
|
114
|
+
params: params || {},
|
|
115
|
+
}));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async send(message) {
|
|
120
|
+
return this.request(message.method, message.params);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
onMessage(handler) {
|
|
124
|
+
this.messageHandlers.push(handler);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async disconnect() {
|
|
128
|
+
this.connected = false;
|
|
129
|
+
if (this.ws) {
|
|
130
|
+
this.ws.close();
|
|
131
|
+
this.ws = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission Checker — 6 modes from decompiled Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Integrates with prompt system for interactive permission in default mode,
|
|
5
|
+
* injection checking for Bash commands, and path validation for file ops.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { requiresPermission } from './prompt.mjs';
|
|
9
|
+
import { checkInjection } from './injection-check.mjs';
|
|
10
|
+
import { validatePath } from './path-check.mjs';
|
|
11
|
+
|
|
12
|
+
export function createPermissionChecker(config = {}) {
|
|
13
|
+
const mode = config.defaultMode || process.env.CLAUDE_CODE_PERMISSION_MODE || 'default';
|
|
14
|
+
const rl = config.rl || null; // readline interface for prompts
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
mode,
|
|
18
|
+
async check(toolName, input) {
|
|
19
|
+
// Always run injection check on Bash commands
|
|
20
|
+
if (toolName === 'Bash' && input?.command) {
|
|
21
|
+
const injection = checkInjection(input.command);
|
|
22
|
+
if (!injection.safe) {
|
|
23
|
+
return false; // block dangerous commands
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Always validate file paths for file operations
|
|
28
|
+
if (['Edit', 'Write', 'Read', 'MultiEdit'].includes(toolName) && input?.file_path) {
|
|
29
|
+
const pathResult = validatePath(input.file_path, { write: toolName !== 'Read' });
|
|
30
|
+
if (!pathResult.safe) {
|
|
31
|
+
return false; // block unsafe paths
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
switch (mode) {
|
|
36
|
+
case 'bypassPermissions': return true;
|
|
37
|
+
case 'acceptEdits':
|
|
38
|
+
// Allow file ops, block Bash/Agent unless rl available
|
|
39
|
+
if (toolName === 'Bash' || toolName === 'Agent') {
|
|
40
|
+
return !requiresPermission(toolName) || !!config.bypassBash;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
case 'auto': return true; // AI decides
|
|
44
|
+
case 'dontAsk': return false; // deny everything not pre-approved
|
|
45
|
+
case 'plan': return toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep';
|
|
46
|
+
case 'default':
|
|
47
|
+
default:
|
|
48
|
+
// In default mode, safe tools pass through
|
|
49
|
+
if (!requiresPermission(toolName)) return true;
|
|
50
|
+
// Without a readline interface, allow (headless mode)
|
|
51
|
+
if (!rl) return true;
|
|
52
|
+
// With rl, would call promptPermission — but that's async/interactive
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Injection Check — detect dangerous shell patterns.
|
|
3
|
+
*
|
|
4
|
+
* Scans commands for common injection vectors before allowing
|
|
5
|
+
* Bash tool execution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DANGEROUS_PATTERNS = [
|
|
9
|
+
{ pattern: /;\s*rm\s+-rf\s+\//, label: 'rm -rf /' },
|
|
10
|
+
{ pattern: /\|\s*sh\b/, label: 'pipe to sh' },
|
|
11
|
+
{ pattern: /\|\s*bash\b/, label: 'pipe to bash' },
|
|
12
|
+
{ pattern: /`[^`]+`/, label: 'backtick execution' },
|
|
13
|
+
{ pattern: /\$\([^)]+\)/, label: 'command substitution' },
|
|
14
|
+
{ pattern: />\s*\/etc\//, label: 'write to /etc' },
|
|
15
|
+
{ pattern: />\s*\/usr\//, label: 'write to /usr' },
|
|
16
|
+
{ pattern: /curl\s.*\|\s*(bash|sh)/, label: 'curl pipe to shell' },
|
|
17
|
+
{ pattern: /wget\s.*\|\s*(bash|sh)/, label: 'wget pipe to shell' },
|
|
18
|
+
{ pattern: /mkfs\./, label: 'filesystem format' },
|
|
19
|
+
{ pattern: /dd\s+if=.*of=\/dev\//, label: 'dd to device' },
|
|
20
|
+
{ pattern: /:\(\)\s*\{.*\|.*&\s*\}/, label: 'fork bomb' },
|
|
21
|
+
{ pattern: /chmod\s+777\s+\//, label: 'chmod 777 root' },
|
|
22
|
+
{ pattern: />\s*\/dev\/sda/, label: 'write to disk device' },
|
|
23
|
+
{ pattern: /eval\s+"?\$/, label: 'eval variable' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check a command string for injection patterns.
|
|
28
|
+
* @param {string} command - shell command to check
|
|
29
|
+
* @returns {{ safe: boolean, pattern?: string, label?: string }}
|
|
30
|
+
*/
|
|
31
|
+
export function checkInjection(command) {
|
|
32
|
+
if (typeof command !== 'string') {
|
|
33
|
+
return { safe: false, label: 'non-string command' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const { pattern, label } of DANGEROUS_PATTERNS) {
|
|
37
|
+
if (pattern.test(command)) {
|
|
38
|
+
return { safe: false, pattern: pattern.source, label };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { safe: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the list of dangerous patterns (for display/testing).
|
|
47
|
+
* @returns {Array<{ pattern: RegExp, label: string }>}
|
|
48
|
+
*/
|
|
49
|
+
export function getDangerousPatterns() {
|
|
50
|
+
return DANGEROUS_PATTERNS.map(({ pattern, label }) => ({ pattern, label }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if a command uses any elevated privilege patterns.
|
|
55
|
+
* @param {string} command
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function usesElevation(command) {
|
|
59
|
+
return /\bsudo\b/.test(command) || /\bsu\s+-?\s/.test(command) || /\bdoas\b/.test(command);
|
|
60
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Path Sanitization — validate file paths before allowing access.
|
|
3
|
+
*
|
|
4
|
+
* Prevents directory traversal, blocks sensitive files, and normalizes paths.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
/** File patterns that should never be read or written. */
|
|
10
|
+
const SENSITIVE_PATTERNS = [
|
|
11
|
+
/\.env$/,
|
|
12
|
+
/\.env\..+$/,
|
|
13
|
+
/credentials\.json$/,
|
|
14
|
+
/credentials\.yaml$/,
|
|
15
|
+
/\.pem$/,
|
|
16
|
+
/\.key$/,
|
|
17
|
+
/id_rsa$/,
|
|
18
|
+
/id_ed25519$/,
|
|
19
|
+
/\.ssh\/config$/,
|
|
20
|
+
/\.netrc$/,
|
|
21
|
+
/\.pgpass$/,
|
|
22
|
+
/\.aws\/credentials$/,
|
|
23
|
+
/\.docker\/config\.json$/,
|
|
24
|
+
/secrets\.yaml$/,
|
|
25
|
+
/secrets\.json$/,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Directories that should never be written to. */
|
|
29
|
+
const PROTECTED_DIRS = [
|
|
30
|
+
'/etc',
|
|
31
|
+
'/usr',
|
|
32
|
+
'/sbin',
|
|
33
|
+
'/boot',
|
|
34
|
+
'/sys',
|
|
35
|
+
'/proc',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate a file path for safety.
|
|
40
|
+
* @param {string} filePath - the path to validate
|
|
41
|
+
* @param {object} [options]
|
|
42
|
+
* @param {string} [options.cwd] - current working directory (default: process.cwd())
|
|
43
|
+
* @param {boolean} [options.write] - whether this is a write operation
|
|
44
|
+
* @returns {{ safe: boolean, resolved: string, reason?: string, warning?: string }}
|
|
45
|
+
*/
|
|
46
|
+
export function validatePath(filePath, options = {}) {
|
|
47
|
+
if (typeof filePath !== 'string' || filePath.trim().length === 0) {
|
|
48
|
+
return { safe: false, resolved: '', reason: 'Empty or invalid path' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const resolved = path.resolve(filePath);
|
|
52
|
+
const cwd = options.cwd || process.cwd();
|
|
53
|
+
|
|
54
|
+
// Check for null bytes (path injection)
|
|
55
|
+
if (filePath.includes('\0')) {
|
|
56
|
+
return { safe: false, resolved, reason: 'Null byte in path' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check sensitive file patterns
|
|
60
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
61
|
+
if (pattern.test(resolved) || pattern.test(path.basename(resolved))) {
|
|
62
|
+
return { safe: false, resolved, reason: 'Sensitive file' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check protected directories for writes
|
|
67
|
+
if (options.write) {
|
|
68
|
+
for (const dir of PROTECTED_DIRS) {
|
|
69
|
+
if (resolved.startsWith(dir + '/') || resolved === dir) {
|
|
70
|
+
return { safe: false, resolved, reason: `Protected directory: ${dir}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check for traversal outside cwd
|
|
76
|
+
let warning;
|
|
77
|
+
if (!resolved.startsWith(cwd) && !resolved.startsWith('/tmp')) {
|
|
78
|
+
warning = 'Path is outside the current working directory';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { safe: true, resolved, warning };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a filename matches sensitive patterns.
|
|
86
|
+
* @param {string} filename
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function isSensitiveFile(filename) {
|
|
90
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
91
|
+
if (pattern.test(filename)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get list of sensitive patterns (for display/testing).
|
|
98
|
+
* @returns {RegExp[]}
|
|
99
|
+
*/
|
|
100
|
+
export function getSensitivePatterns() {
|
|
101
|
+
return [...SENSITIVE_PATTERNS];
|
|
102
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission Prompts — interactive yes/no for dangerous operations.
|
|
3
|
+
*
|
|
4
|
+
* Used in "default" permission mode to ask the user before executing
|
|
5
|
+
* potentially dangerous tool calls (Bash, Edit, Write, Agent).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prompt the user for permission to execute a tool.
|
|
10
|
+
* @param {string} toolName - tool being invoked
|
|
11
|
+
* @param {object} input - tool input
|
|
12
|
+
* @param {object} rl - readline interface with .question()
|
|
13
|
+
* @returns {Promise<boolean>} true if allowed
|
|
14
|
+
*/
|
|
15
|
+
export async function promptPermission(toolName, input, rl) {
|
|
16
|
+
if (!rl || typeof rl.question !== 'function') {
|
|
17
|
+
// No readline available — deny by default
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const summary = formatToolSummary(toolName, input);
|
|
22
|
+
return new Promise(resolve => {
|
|
23
|
+
rl.question(`Allow ${summary}? [y/N] `, answer => {
|
|
24
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format a human-readable summary of a tool call.
|
|
31
|
+
* @param {string} toolName
|
|
32
|
+
* @param {object} input
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function formatToolSummary(toolName, input) {
|
|
36
|
+
switch (toolName) {
|
|
37
|
+
case 'Bash':
|
|
38
|
+
return `Bash: ${truncate(input.command || '', 60)}`;
|
|
39
|
+
case 'Edit':
|
|
40
|
+
return `Edit: ${input.file_path || 'unknown file'}`;
|
|
41
|
+
case 'Write':
|
|
42
|
+
return `Write: ${input.file_path || 'unknown file'} (${(input.content || '').length} chars)`;
|
|
43
|
+
case 'MultiEdit':
|
|
44
|
+
return `MultiEdit: ${input.file_path || 'unknown file'} (${(input.edits || []).length} edits)`;
|
|
45
|
+
case 'Agent':
|
|
46
|
+
return `Agent: ${truncate(input.prompt || '', 40)}`;
|
|
47
|
+
case 'WebFetch':
|
|
48
|
+
return `WebFetch: ${truncate(input.url || '', 50)}`;
|
|
49
|
+
case 'RemoteTrigger':
|
|
50
|
+
return `RemoteTrigger: ${input.url || 'unknown'}`;
|
|
51
|
+
default:
|
|
52
|
+
return `${toolName}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if a tool requires interactive permission in default mode.
|
|
58
|
+
* Read-only tools are always allowed.
|
|
59
|
+
* @param {string} toolName
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function requiresPermission(toolName) {
|
|
63
|
+
const SAFE_TOOLS = new Set([
|
|
64
|
+
'Read', 'Glob', 'Grep', 'LS', 'ToolSearch',
|
|
65
|
+
'AskUser', 'CronList', 'TodoWrite',
|
|
66
|
+
]);
|
|
67
|
+
return !SAFE_TOOLS.has(toolName);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function truncate(str, maxLen) {
|
|
71
|
+
if (str.length <= maxLen) return str;
|
|
72
|
+
return str.substring(0, maxLen - 3) + '...';
|
|
73
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox — wrap commands in platform-specific sandboxes.
|
|
3
|
+
*
|
|
4
|
+
* Linux: bubblewrap (bwrap)
|
|
5
|
+
* macOS: sandbox-exec (seatbelt)
|
|
6
|
+
* Windows/other: passthrough (no sandbox)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class Sandbox {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} [platform] - override process.platform
|
|
12
|
+
*/
|
|
13
|
+
constructor(platform) {
|
|
14
|
+
this.platform = platform || process.platform;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wrap a command to run inside a sandbox.
|
|
19
|
+
* @param {string} command - the command to sandbox
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {string[]} [options.allowWrite] - directories to allow writes
|
|
22
|
+
* @param {string[]} [options.allowNet] - allow network access (macOS)
|
|
23
|
+
* @param {boolean} [options.allowDevices] - allow device access
|
|
24
|
+
* @returns {string} sandboxed command
|
|
25
|
+
*/
|
|
26
|
+
wrapCommand(command, options = {}) {
|
|
27
|
+
if (this.platform === 'linux') return this.bubblewrap(command, options);
|
|
28
|
+
if (this.platform === 'darwin') return this.seatbelt(command, options);
|
|
29
|
+
return command; // fallback: no sandbox
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Linux sandbox using bubblewrap.
|
|
34
|
+
* Creates a minimal read-only root with /dev, /proc, /tmp.
|
|
35
|
+
*/
|
|
36
|
+
bubblewrap(command, opts = {}) {
|
|
37
|
+
const args = [
|
|
38
|
+
'--ro-bind', '/', '/',
|
|
39
|
+
'--dev', '/dev',
|
|
40
|
+
'--proc', '/proc',
|
|
41
|
+
'--tmpfs', '/tmp',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Allow specific writable directories
|
|
45
|
+
if (opts.allowWrite) {
|
|
46
|
+
for (const dir of opts.allowWrite) {
|
|
47
|
+
if (typeof dir === 'string' && dir.length > 0) {
|
|
48
|
+
args.push('--bind', dir, dir);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Allow /dev access if requested
|
|
54
|
+
if (opts.allowDevices) {
|
|
55
|
+
args.push('--dev-bind', '/dev', '/dev');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return `bwrap ${args.join(' ')} -- ${command}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* macOS sandbox using sandbox-exec with a seatbelt profile.
|
|
63
|
+
* Returns a sandbox-exec wrapped command with a generated profile.
|
|
64
|
+
*/
|
|
65
|
+
seatbelt(command, opts = {}) {
|
|
66
|
+
const rules = [
|
|
67
|
+
'(version 1)',
|
|
68
|
+
'(deny default)',
|
|
69
|
+
'(allow process-exec)',
|
|
70
|
+
'(allow process-fork)',
|
|
71
|
+
'(allow file-read*)',
|
|
72
|
+
'(allow sysctl-read)',
|
|
73
|
+
'(allow mach-lookup)',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Allow writes to specific directories
|
|
77
|
+
if (opts.allowWrite) {
|
|
78
|
+
for (const dir of opts.allowWrite) {
|
|
79
|
+
if (typeof dir === 'string' && dir.length > 0) {
|
|
80
|
+
rules.push(`(allow file-write* (subpath "${dir}"))`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Allow /tmp writes by default
|
|
86
|
+
rules.push('(allow file-write* (subpath "/tmp"))');
|
|
87
|
+
|
|
88
|
+
// Allow network if requested
|
|
89
|
+
if (opts.allowNet) {
|
|
90
|
+
rules.push('(allow network*)');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const profile = rules.join('\n');
|
|
94
|
+
// Escape single quotes in profile for shell
|
|
95
|
+
const escaped = profile.replace(/'/g, "'\\''");
|
|
96
|
+
return `sandbox-exec -p '${escaped}' ${command}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if sandbox tooling is available on this platform.
|
|
101
|
+
* @returns {{ available: boolean, tool: string }}
|
|
102
|
+
*/
|
|
103
|
+
check() {
|
|
104
|
+
if (this.platform === 'linux') {
|
|
105
|
+
return { available: true, tool: 'bwrap' };
|
|
106
|
+
}
|
|
107
|
+
if (this.platform === 'darwin') {
|
|
108
|
+
return { available: true, tool: 'sandbox-exec' };
|
|
109
|
+
}
|
|
110
|
+
return { available: false, tool: 'none' };
|
|
111
|
+
}
|
|
112
|
+
}
|