@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.
Files changed (63) hide show
  1. package/package.json +27 -0
  2. package/src/agents/loader.mjs +89 -0
  3. package/src/agents/parser.mjs +98 -0
  4. package/src/agents/teams.mjs +123 -0
  5. package/src/auth/oauth.mjs +220 -0
  6. package/src/config/cli-args.mjs +139 -0
  7. package/src/config/env.mjs +261 -0
  8. package/src/config/settings.mjs +119 -0
  9. package/src/core/agent-loop.mjs +462 -0
  10. package/src/core/cache.mjs +105 -0
  11. package/src/core/checkpoints.mjs +141 -0
  12. package/src/core/context-manager.mjs +198 -0
  13. package/src/core/providers.mjs +219 -0
  14. package/src/core/rate-limiter.mjs +119 -0
  15. package/src/core/scheduler.mjs +173 -0
  16. package/src/core/session.mjs +143 -0
  17. package/src/core/streaming.mjs +182 -0
  18. package/src/core/system-prompt.mjs +135 -0
  19. package/src/hooks/engine.mjs +162 -0
  20. package/src/index.mjs +220 -0
  21. package/src/mcp/client.mjs +253 -0
  22. package/src/mcp/transport-shttp.mjs +130 -0
  23. package/src/mcp/transport-sse.mjs +131 -0
  24. package/src/mcp/transport-ws.mjs +134 -0
  25. package/src/permissions/checker.mjs +57 -0
  26. package/src/permissions/injection-check.mjs +60 -0
  27. package/src/permissions/path-check.mjs +102 -0
  28. package/src/permissions/prompt.mjs +73 -0
  29. package/src/permissions/sandbox.mjs +112 -0
  30. package/src/plugins/loader.mjs +138 -0
  31. package/src/skills/loader.mjs +147 -0
  32. package/src/skills/runner.mjs +55 -0
  33. package/src/telemetry/index.mjs +96 -0
  34. package/src/tools/agent.mjs +127 -0
  35. package/src/tools/ask-user.mjs +61 -0
  36. package/src/tools/bash.mjs +148 -0
  37. package/src/tools/cron-create.mjs +120 -0
  38. package/src/tools/cron-delete.mjs +49 -0
  39. package/src/tools/cron-list.mjs +37 -0
  40. package/src/tools/edit.mjs +82 -0
  41. package/src/tools/enter-worktree.mjs +69 -0
  42. package/src/tools/exit-worktree.mjs +57 -0
  43. package/src/tools/glob.mjs +117 -0
  44. package/src/tools/grep.mjs +127 -0
  45. package/src/tools/ls.mjs +58 -0
  46. package/src/tools/lsp.mjs +115 -0
  47. package/src/tools/multi-edit.mjs +94 -0
  48. package/src/tools/notebook-edit.mjs +96 -0
  49. package/src/tools/read-mcp-resource.mjs +57 -0
  50. package/src/tools/read.mjs +138 -0
  51. package/src/tools/registry.mjs +114 -0
  52. package/src/tools/remote-trigger.mjs +84 -0
  53. package/src/tools/send-message.mjs +64 -0
  54. package/src/tools/skill.mjs +52 -0
  55. package/src/tools/todo-write.mjs +68 -0
  56. package/src/tools/tool-search.mjs +77 -0
  57. package/src/tools/web-fetch.mjs +65 -0
  58. package/src/tools/web-search.mjs +89 -0
  59. package/src/tools/write.mjs +55 -0
  60. package/src/ui/commands.mjs +499 -0
  61. package/src/ui/ink-app.mjs +231 -0
  62. package/src/ui/repl.mjs +144 -0
  63. 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
+ }