fix: reproducible build — pin deps, patch commander, move stubs to stubs/
- Move private package stubs to stubs/ (tracked), reference via file: in package.json
Stubs: color-diff-napi, modifiers-napi, @ant/claude-for-chrome-mcp,
@anthropic-ai/mcpb, @anthropic-ai/sandbox-runtime
- Add patches/commander@14.0.3.patch via pnpm patch:
Allow multi-char short flags (/^-[^-]+$/) so -d2e works with commander v14
- Pin all dependency versions to exact versions from original cli.js bundle
(extracted from node_modules package.json files in source map)
- Add @anthropic-ai/bedrock-sdk, foundry-sdk, vertex-sdk to dependencies
- Update .gitignore: node_modules/ fully ignored (regenerated by pnpm install)
- Update scripts to use plain `bun` instead of hardcoded path
- Verified: rm -rf node_modules && pnpm install && bun run build.ts succeeds
Output: dist/cli.js (22.1MB), `bun dist/cli.js --version` → 2.1.88 (Claude Code)
This commit is contained in:
13
claude-code-source/stubs/@ant/claude-for-chrome-mcp/index.js
Normal file
13
claude-code-source/stubs/@ant/claude-for-chrome-mcp/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export function createClaudeForChromeMcpServer(ctx) {
|
||||
return {
|
||||
connect: async () => {},
|
||||
close: async () => {},
|
||||
}
|
||||
}
|
||||
|
||||
export const BROWSER_TOOLS = []
|
||||
|
||||
// Type stubs (not needed at runtime but referenced as types)
|
||||
export class ClaudeForChromeContext {}
|
||||
export class Logger {}
|
||||
export class PermissionMode {}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"@ant/claude-for-chrome-mcp","version":"1.0.0","type":"module","main":"index.js","exports":{".":"./index.js"}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,546 @@
|
||||
export const BROWSER_TOOLS = [
|
||||
{
|
||||
name: "javascript_tool",
|
||||
description:
|
||||
"Execute JavaScript code in the context of the current page. The code runs in the page's context and can interact with the DOM, window object, and page variables. Returns the result of the last expression or any thrown errors. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
description: "Must be set to 'javascript_exec'",
|
||||
},
|
||||
text: {
|
||||
type: "string",
|
||||
description:
|
||||
"The JavaScript code to execute. The code will be evaluated in the page context. The result of the last expression will be returned automatically. Do NOT use 'return' statements - just write the expression you want to evaluate (e.g., 'window.myData.value' not 'return window.myData.value'). You can access and modify the DOM, call page functions, and interact with page variables.",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to execute the code in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["action", "text", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_page",
|
||||
description:
|
||||
"Get an accessibility tree representation of elements on the page. By default returns all elements including non-visible ones. Output is limited to 50000 characters by default. If the output exceeds this limit, you will receive an error asking you to specify a smaller depth or focus on a specific element using ref_id. Optionally filter for only interactive elements. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
filter: {
|
||||
type: "string",
|
||||
enum: ["interactive", "all"],
|
||||
description:
|
||||
'Filter elements: "interactive" for buttons/links/inputs only, "all" for all elements including non-visible ones (default: all elements)',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to read from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
depth: {
|
||||
type: "number",
|
||||
description:
|
||||
"Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.",
|
||||
},
|
||||
ref_id: {
|
||||
type: "string",
|
||||
description:
|
||||
"Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.",
|
||||
},
|
||||
max_chars: {
|
||||
type: "number",
|
||||
description:
|
||||
"Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "find",
|
||||
description:
|
||||
'Find elements on the page using natural language. Can search for elements by their purpose (e.g., "search bar", "login button") or by text content (e.g., "organic mango product"). Returns up to 20 matching elements with references that can be used with other tools. If more than 20 matches exist, you\'ll be notified to use a more specific query. If you don\'t have a valid tab ID, use tabs_context_mcp first to get available tabs.',
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description:
|
||||
'Natural language description of what to find (e.g., "search bar", "add to cart button", "product title containing organic")',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to search in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["query", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form_input",
|
||||
description:
|
||||
"Set values in form elements using element reference ID from the read_page tool. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: {
|
||||
type: "string",
|
||||
description:
|
||||
'Element reference ID from the read_page tool (e.g., "ref_1", "ref_2")',
|
||||
},
|
||||
value: {
|
||||
type: ["string", "boolean", "number"],
|
||||
description:
|
||||
"The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to set form value in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["ref", "value", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "computer",
|
||||
description: `Use a mouse and keyboard to interact with a web browser, and take screenshots. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.\n* Whenever you intend to click on an element like an icon, you should consult a screenshot to determine the coordinates of the element before moving the cursor.\n* If you tried clicking on a program or link but it failed to load, even after waiting, try adjusting your click location so that the tip of the cursor visually falls on the element that you want to click.\n* Make sure to click any buttons, links, icons, etc with the cursor tip in the center of the element. Don't click boxes on their edges unless asked.`,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"left_click",
|
||||
"right_click",
|
||||
"type",
|
||||
"screenshot",
|
||||
"wait",
|
||||
"scroll",
|
||||
"key",
|
||||
"left_click_drag",
|
||||
"double_click",
|
||||
"triple_click",
|
||||
"zoom",
|
||||
"scroll_to",
|
||||
"hover",
|
||||
],
|
||||
description:
|
||||
"The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.",
|
||||
},
|
||||
coordinate: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
description:
|
||||
"(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.",
|
||||
},
|
||||
text: {
|
||||
type: "string",
|
||||
description:
|
||||
'The text to type (for `type` action) or the key(s) to press (for `key` action). For `key` action: Provide space-separated keys (e.g., "Backspace Backspace Delete"). Supports keyboard shortcuts using the platform\'s modifier key (use "cmd" on Mac, "ctrl" on Windows/Linux, e.g., "cmd+a" or "ctrl+a" for select all).',
|
||||
},
|
||||
duration: {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
maximum: 30,
|
||||
description:
|
||||
"The number of seconds to wait. Required for `wait`. Maximum 30 seconds.",
|
||||
},
|
||||
scroll_direction: {
|
||||
type: "string",
|
||||
enum: ["up", "down", "left", "right"],
|
||||
description: "The direction to scroll. Required for `scroll`.",
|
||||
},
|
||||
scroll_amount: {
|
||||
type: "number",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
description:
|
||||
"The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.",
|
||||
},
|
||||
start_coordinate: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
description:
|
||||
"(x, y): The starting coordinates for `left_click_drag`.",
|
||||
},
|
||||
region: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
minItems: 4,
|
||||
maxItems: 4,
|
||||
description:
|
||||
"(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.",
|
||||
},
|
||||
repeat: {
|
||||
type: "number",
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description:
|
||||
"Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.",
|
||||
},
|
||||
ref: {
|
||||
type: "string",
|
||||
description:
|
||||
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Required for `scroll_to` action. Can be used as alternative to `coordinate` for click actions.',
|
||||
},
|
||||
modifiers: {
|
||||
type: "string",
|
||||
description:
|
||||
'Modifier keys for click actions. Supports: "ctrl", "shift", "alt", "cmd" (or "meta"), "win" (or "windows"). Can be combined with "+" (e.g., "ctrl+shift", "cmd+alt"). Optional.',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to execute the action on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["action", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "navigate",
|
||||
description:
|
||||
"Navigate to a URL, or go forward/back in browser history. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: {
|
||||
type: "string",
|
||||
description:
|
||||
'The URL to navigate to. Can be provided with or without protocol (defaults to https://). Use "forward" to go forward in history or "back" to go back in history.',
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to navigate. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["url", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resize_window",
|
||||
description:
|
||||
"Resize the current browser window to specified dimensions. Useful for testing responsive designs or setting up specific screen sizes. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
width: {
|
||||
type: "number",
|
||||
description: "Target window width in pixels",
|
||||
},
|
||||
height: {
|
||||
type: "number",
|
||||
description: "Target window height in pixels",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to get the window for. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["width", "height", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gif_creator",
|
||||
description:
|
||||
"Manage GIF recording and export for browser automation sessions. Control when to start/stop recording browser actions (clicks, scrolls, navigation), then export as an animated GIF with visual overlays (click indicators, action labels, progress bar, watermark). All operations are scoped to the tab's group. When starting recording, take a screenshot immediately after to capture the initial state as the first frame. When stopping recording, take a screenshot immediately before to capture the final state as the last frame. For export, either provide 'coordinate' to drag/drop upload to a page element, or set 'download: true' to download the GIF.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["start_recording", "stop_recording", "export", "clear"],
|
||||
description:
|
||||
"Action to perform: 'start_recording' (begin capturing), 'stop_recording' (stop capturing but keep frames), 'export' (generate and export GIF), 'clear' (discard frames)",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to identify which tab group this operation applies to",
|
||||
},
|
||||
download: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Always set this to true for the 'export' action only. This causes the gif to be downloaded in the browser.",
|
||||
},
|
||||
filename: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional filename for exported GIF (default: 'recording-[timestamp].gif'). For 'export' action only.",
|
||||
},
|
||||
options: {
|
||||
type: "object",
|
||||
description:
|
||||
"Optional GIF enhancement options for 'export' action. Properties: showClickIndicators (bool), showDragPaths (bool), showActionLabels (bool), showProgressBar (bool), showWatermark (bool), quality (number 1-30). All default to true except quality (default: 10).",
|
||||
properties: {
|
||||
showClickIndicators: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Show orange circles at click locations (default: true)",
|
||||
},
|
||||
showDragPaths: {
|
||||
type: "boolean",
|
||||
description: "Show red arrows for drag actions (default: true)",
|
||||
},
|
||||
showActionLabels: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Show black labels describing actions (default: true)",
|
||||
},
|
||||
showProgressBar: {
|
||||
type: "boolean",
|
||||
description: "Show orange progress bar at bottom (default: true)",
|
||||
},
|
||||
showWatermark: {
|
||||
type: "boolean",
|
||||
description: "Show Claude logo watermark (default: true)",
|
||||
},
|
||||
quality: {
|
||||
type: "number",
|
||||
description:
|
||||
"GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["action", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "upload_image",
|
||||
description:
|
||||
"Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
imageId: {
|
||||
type: "string",
|
||||
description:
|
||||
"ID of a previously captured screenshot (from the computer tool's screenshot action) or a user-uploaded image",
|
||||
},
|
||||
ref: {
|
||||
type: "string",
|
||||
description:
|
||||
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Use this for file inputs (especially hidden ones) or specific elements. Provide either ref or coordinate, not both.',
|
||||
},
|
||||
coordinate: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "number",
|
||||
},
|
||||
description:
|
||||
"Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.",
|
||||
},
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID where the target element is located. This is where the image will be uploaded to.",
|
||||
},
|
||||
filename: {
|
||||
type: "string",
|
||||
description:
|
||||
'Optional filename for the uploaded file (default: "image.png")',
|
||||
},
|
||||
},
|
||||
required: ["imageId", "tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_page_text",
|
||||
description:
|
||||
"Extract raw text content from the page, prioritizing article content. Ideal for reading articles, blog posts, or other text-heavy pages. Returns plain text without HTML formatting. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to extract text from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tabs_context_mcp",
|
||||
title: "Tabs Context",
|
||||
description:
|
||||
"Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
createIfEmpty: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tabs_create_mcp",
|
||||
title: "Tabs Create",
|
||||
description:
|
||||
"Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_plan",
|
||||
description:
|
||||
"Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
domains: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
description:
|
||||
"List of domains you will visit (e.g., ['github.com', 'stackoverflow.com']). These domains will be approved for the session when the user accepts the plan.",
|
||||
},
|
||||
approach: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const },
|
||||
description:
|
||||
"High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.",
|
||||
},
|
||||
},
|
||||
required: ["domains", "approach"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_console_messages",
|
||||
description:
|
||||
"Read browser console messages (console.log, console.error, console.warn, etc.) from a specific tab. Useful for debugging JavaScript errors, viewing application logs, or understanding what's happening in the browser console. Returns console messages from the current domain only. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs. IMPORTANT: Always provide a pattern to filter messages - without a pattern, you may get too many irrelevant messages.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to read console messages from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
onlyErrors: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If true, only return error and exception messages. Default is false (return all message types).",
|
||||
},
|
||||
clear: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.",
|
||||
},
|
||||
pattern: {
|
||||
type: "string",
|
||||
description:
|
||||
"Regex pattern to filter console messages. Only messages matching this pattern will be returned (e.g., 'error|warning' to find errors and warnings, 'MyApp' to filter app-specific logs). You should always provide a pattern to avoid getting too many irrelevant messages.",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description:
|
||||
"Maximum number of messages to return. Defaults to 100. Increase only if you need more results.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_network_requests",
|
||||
description:
|
||||
"Read HTTP network requests (XHR, Fetch, documents, images, etc.) from a specific tab. Useful for debugging API calls, monitoring network activity, or understanding what requests a page is making. Returns all network requests made by the current page, including cross-origin requests. Requests are automatically cleared when the page navigates to a different domain. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to read network requests from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
urlPattern: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional URL pattern to filter requests. Only requests whose URL contains this string will be returned (e.g., '/api/' to filter API calls, 'example.com' to filter by domain).",
|
||||
},
|
||||
clear: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description:
|
||||
"Maximum number of requests to return. Defaults to 100. Increase only if you need more results.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shortcuts_list",
|
||||
description:
|
||||
"List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to list shortcuts from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shortcuts_execute",
|
||||
description:
|
||||
"Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tabId: {
|
||||
type: "number",
|
||||
description:
|
||||
"Tab ID to execute the shortcut on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
|
||||
},
|
||||
shortcutId: {
|
||||
type: "string",
|
||||
description: "The ID of the shortcut to execute",
|
||||
},
|
||||
command: {
|
||||
type: "string",
|
||||
description:
|
||||
"The command name of the shortcut to execute (e.g., 'debug', 'summarize'). Do not include the leading slash.",
|
||||
},
|
||||
},
|
||||
required: ["tabId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "switch_browser",
|
||||
description:
|
||||
"Switch which Chrome browser is used for browser automation. Call this when the user wants to connect to a different Chrome browser. Broadcasts a connection request to all Chrome browsers with the extension installed — the user clicks 'Connect' in the desired browser.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
export { BridgeClient, createBridgeClient } from "./bridgeClient.js";
|
||||
export { BROWSER_TOOLS } from "./browserTools.js";
|
||||
export {
|
||||
createChromeSocketClient,
|
||||
createClaudeForChromeMcpServer,
|
||||
} from "./mcpServer.js";
|
||||
export { localPlatformLabel } from "./types.js";
|
||||
export type {
|
||||
BridgeConfig,
|
||||
ChromeExtensionInfo,
|
||||
ClaudeForChromeContext,
|
||||
Logger,
|
||||
PermissionMode,
|
||||
SocketClient,
|
||||
} from "./types.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { createBridgeClient } from "./bridgeClient.js";
|
||||
import { BROWSER_TOOLS } from "./browserTools.js";
|
||||
import { createMcpSocketClient } from "./mcpSocketClient.js";
|
||||
import { createMcpSocketPool } from "./mcpSocketPool.js";
|
||||
import { handleToolCall } from "./toolCalls.js";
|
||||
import type { ClaudeForChromeContext, SocketClient } from "./types.js";
|
||||
|
||||
/**
|
||||
* Create the socket/bridge client for the Chrome extension MCP server.
|
||||
* Exported so Desktop can share a single instance between the registered
|
||||
* MCP server and the InternalMcpServerManager (CCD sessions).
|
||||
*/
|
||||
export function createChromeSocketClient(
|
||||
context: ClaudeForChromeContext,
|
||||
): SocketClient {
|
||||
return context.bridgeConfig
|
||||
? createBridgeClient(context)
|
||||
: context.getSocketPaths
|
||||
? createMcpSocketPool(context)
|
||||
: createMcpSocketClient(context);
|
||||
}
|
||||
|
||||
export function createClaudeForChromeMcpServer(
|
||||
context: ClaudeForChromeContext,
|
||||
existingSocketClient?: SocketClient,
|
||||
): Server {
|
||||
const { serverName, logger } = context;
|
||||
|
||||
// Choose transport: bridge (WebSocket) > socket pool (multi-profile) > single socket.
|
||||
const socketClient =
|
||||
existingSocketClient ?? createChromeSocketClient(context);
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: serverName,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
if (context.isDisabled?.()) {
|
||||
return { tools: [] };
|
||||
}
|
||||
return {
|
||||
tools: context.bridgeConfig
|
||||
? BROWSER_TOOLS
|
||||
: BROWSER_TOOLS.filter((t) => t.name !== "switch_browser"),
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request): Promise<CallToolResult> => {
|
||||
logger.info(`[${serverName}] Executing tool: ${request.params.name}`);
|
||||
|
||||
return handleToolCall(
|
||||
context,
|
||||
socketClient,
|
||||
request.params.name,
|
||||
request.params.arguments || {},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
socketClient.setNotificationHandler((notification) => {
|
||||
logger.info(
|
||||
`[${serverName}] Forwarding MCP notification: ${notification.method}`,
|
||||
);
|
||||
server
|
||||
.notification({
|
||||
method: notification.method,
|
||||
params: notification.params,
|
||||
})
|
||||
.catch((error) => {
|
||||
// Server may not be connected yet (e.g., during startup or after disconnect)
|
||||
logger.info(
|
||||
`[${serverName}] Failed to forward MCP notification: ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { promises as fsPromises } from "fs";
|
||||
import { createConnection } from "net";
|
||||
import type { Socket } from "net";
|
||||
import { platform } from "os";
|
||||
import { dirname } from "path";
|
||||
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from "./types.js";
|
||||
|
||||
export class SocketConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SocketConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolRequest {
|
||||
method: string; // "execute_tool"
|
||||
params?: {
|
||||
client_id?: string; // "desktop" | "claude-code"
|
||||
tool?: string;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolResponse {
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type SocketMessage = ToolResponse | Notification;
|
||||
|
||||
function isToolResponse(message: SocketMessage): message is ToolResponse {
|
||||
return "result" in message || "error" in message;
|
||||
}
|
||||
|
||||
function isNotification(message: SocketMessage): message is Notification {
|
||||
return "method" in message && typeof message.method === "string";
|
||||
}
|
||||
|
||||
class McpSocketClient {
|
||||
private socket: Socket | null = null;
|
||||
private connected = false;
|
||||
private connecting = false;
|
||||
private responseCallback: ((response: ToolResponse) => void) | null = null;
|
||||
private notificationHandler: ((notification: Notification) => void) | null =
|
||||
null;
|
||||
private responseBuffer = Buffer.alloc(0);
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private reconnectDelay = 1000;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private context: ClaudeForChromeContext;
|
||||
// When true, disables automatic reconnection. Used by McpSocketPool which
|
||||
// manages reconnection externally by rescanning available sockets.
|
||||
public disableAutoReconnect = false;
|
||||
|
||||
constructor(context: ClaudeForChromeContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
const { serverName, logger } = this.context;
|
||||
|
||||
if (this.connecting) {
|
||||
logger.info(
|
||||
`[${serverName}] Already connecting, skipping duplicate attempt`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeSocket();
|
||||
this.connecting = true;
|
||||
|
||||
const socketPath =
|
||||
this.context.getSocketPath?.() ?? this.context.socketPath;
|
||||
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`);
|
||||
|
||||
try {
|
||||
await this.validateSocketSecurity(socketPath);
|
||||
} catch (error) {
|
||||
this.connecting = false;
|
||||
logger.info(`[${serverName}] Security validation failed:`, error);
|
||||
// Don't retry on security failures (wrong perms/owner) - those won't
|
||||
// self-resolve. Only the error handler retries on transient errors.
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = createConnection(socketPath);
|
||||
|
||||
// Timeout the initial connection attempt - if socket file exists but native
|
||||
// host is dead, the connect can hang indefinitely
|
||||
const connectTimeout = setTimeout(() => {
|
||||
if (!this.connected) {
|
||||
logger.info(
|
||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
||||
);
|
||||
this.closeSocket();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
logger.info(`[${serverName}] Successfully connected to bridge server`);
|
||||
});
|
||||
|
||||
this.socket.on("data", (data: Buffer) => {
|
||||
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
|
||||
|
||||
while (this.responseBuffer.length >= 4) {
|
||||
const length = this.responseBuffer.readUInt32LE(0);
|
||||
|
||||
if (this.responseBuffer.length < 4 + length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const messageBytes = this.responseBuffer.slice(4, 4 + length);
|
||||
this.responseBuffer = this.responseBuffer.slice(4 + length);
|
||||
|
||||
try {
|
||||
const message = JSON.parse(
|
||||
messageBytes.toString("utf-8"),
|
||||
) as SocketMessage;
|
||||
|
||||
if (isNotification(message)) {
|
||||
logger.info(
|
||||
`[${serverName}] Received notification: ${message.method}`,
|
||||
);
|
||||
if (this.notificationHandler) {
|
||||
this.notificationHandler(message);
|
||||
}
|
||||
} else if (isToolResponse(message)) {
|
||||
logger.info(`[${serverName}] Received tool response: ${message}`);
|
||||
this.handleResponse(message);
|
||||
} else {
|
||||
logger.info(`[${serverName}] Received unknown message: ${message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`[${serverName}] Failed to parse message:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("error", (error: Error & { code?: string }) => {
|
||||
clearTimeout(connectTimeout);
|
||||
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error);
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
|
||||
if (
|
||||
error.code &&
|
||||
[
|
||||
"ECONNREFUSED", // Native host not listening (stale socket)
|
||||
"ECONNRESET", // Connection reset by peer
|
||||
"EPIPE", // Broken pipe (native host died mid-write)
|
||||
"ENOENT", // Socket file was deleted
|
||||
"EOPNOTSUPP", // Socket file exists but is not a valid socket
|
||||
"ECONNABORTED", // Connection aborted
|
||||
].includes(error.code)
|
||||
) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("close", () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
const { serverName, logger } = this.context;
|
||||
|
||||
if (this.disableAutoReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
logger.info(`[${serverName}] Reconnect already scheduled, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
|
||||
// Give up after extended polling (~50 min). A new ensureConnected() call
|
||||
// from a tool request will restart the cycle if needed.
|
||||
const maxTotalAttempts = 100;
|
||||
if (this.reconnectAttempts > maxTotalAttempts) {
|
||||
logger.info(
|
||||
`[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`,
|
||||
);
|
||||
this.reconnectAttempts = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use aggressive backoff for first 10 attempts, then slow poll every 30s.
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||
30000,
|
||||
);
|
||||
|
||||
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
|
||||
logger.info(
|
||||
`[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${
|
||||
this.reconnectAttempts
|
||||
})`,
|
||||
);
|
||||
} else if (this.reconnectAttempts % 10 === 0) {
|
||||
// Log every 10th slow-poll attempt to avoid log spam
|
||||
logger.info(
|
||||
`[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`,
|
||||
);
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
void this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private handleResponse(response: ToolResponse): void {
|
||||
if (this.responseCallback) {
|
||||
const callback = this.responseCallback;
|
||||
this.responseCallback = null;
|
||||
callback(response);
|
||||
}
|
||||
}
|
||||
|
||||
public setNotificationHandler(
|
||||
handler: (notification: Notification) => void,
|
||||
): void {
|
||||
this.notificationHandler = handler;
|
||||
}
|
||||
|
||||
public async ensureConnected(): Promise<boolean> {
|
||||
const { serverName } = this.context;
|
||||
|
||||
if (this.connected && this.socket) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.socket && !this.connecting) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
// Wait for connection with timeout
|
||||
return new Promise((resolve, reject) => {
|
||||
let checkTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (checkTimeoutId) {
|
||||
clearTimeout(checkTimeoutId);
|
||||
}
|
||||
reject(
|
||||
new SocketConnectionError(
|
||||
`[${serverName}] Connection attempt timed out after 5000ms`,
|
||||
),
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
const checkConnection = () => {
|
||||
if (this.connected) {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
} else {
|
||||
checkTimeoutId = setTimeout(checkConnection, 500);
|
||||
}
|
||||
};
|
||||
checkConnection();
|
||||
});
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
request: ToolRequest,
|
||||
timeoutMs = 30000,
|
||||
): Promise<ToolResponse> {
|
||||
const { serverName } = this.context;
|
||||
|
||||
if (!this.socket) {
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] Cannot send request: not connected`,
|
||||
);
|
||||
}
|
||||
|
||||
const socket = this.socket;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.responseCallback = null;
|
||||
reject(
|
||||
new SocketConnectionError(
|
||||
`[${serverName}] Tool request timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
this.responseCallback = (response) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
const requestJson = JSON.stringify(request);
|
||||
const requestBytes = Buffer.from(requestJson, "utf-8");
|
||||
|
||||
const lengthPrefix = Buffer.allocUnsafe(4);
|
||||
lengthPrefix.writeUInt32LE(requestBytes.length, 0);
|
||||
|
||||
const message = Buffer.concat([lengthPrefix, requestBytes]);
|
||||
socket.write(message);
|
||||
});
|
||||
}
|
||||
|
||||
public async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
_permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown> {
|
||||
const request: ToolRequest = {
|
||||
method: "execute_tool",
|
||||
params: {
|
||||
client_id: this.context.clientTypeId,
|
||||
tool: name,
|
||||
args,
|
||||
},
|
||||
};
|
||||
|
||||
return this.sendRequestWithRetry(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request with automatic retry on connection errors.
|
||||
*
|
||||
* On connection error or timeout, the native host may be a zombie (connected
|
||||
* to dead Chrome). Force reconnect to pick up a fresh native host process
|
||||
* and retry once.
|
||||
*/
|
||||
private async sendRequestWithRetry(request: ToolRequest): Promise<unknown> {
|
||||
const { serverName, logger } = this.context;
|
||||
|
||||
try {
|
||||
return await this.sendRequest(request);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SocketConnectionError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`,
|
||||
);
|
||||
|
||||
this.closeSocket();
|
||||
await this.ensureConnected();
|
||||
|
||||
return await this.sendRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
public async setPermissionMode(
|
||||
_mode: PermissionMode,
|
||||
_allowedDomains?: string[],
|
||||
): Promise<void> {
|
||||
// No-op: permission mode is only supported over the bridge (WebSocket) transport
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
private closeSocket(): void {
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
this.socket.end();
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.closeSocket();
|
||||
this.reconnectAttempts = 0;
|
||||
this.responseBuffer = Buffer.alloc(0);
|
||||
this.responseCallback = null;
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private async validateSocketSecurity(socketPath: string): Promise<void> {
|
||||
const { serverName, logger } = this.context;
|
||||
if (platform() === "win32") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Validate the parent directory permissions if it's the socket directory
|
||||
// (not /tmp itself, which has mode 1777 for legacy single-socket paths)
|
||||
const dirPath = dirname(socketPath);
|
||||
const dirBasename = dirPath.split("/").pop() || "";
|
||||
const isSocketDir = dirBasename.startsWith("claude-mcp-browser-bridge-");
|
||||
if (isSocketDir) {
|
||||
try {
|
||||
const dirStats = await fsPromises.stat(dirPath);
|
||||
if (dirStats.isDirectory()) {
|
||||
const dirMode = dirStats.mode & 0o777;
|
||||
if (dirMode !== 0o700) {
|
||||
throw new Error(
|
||||
`[${serverName}] Insecure socket directory permissions: ${dirMode.toString(
|
||||
8,
|
||||
)} (expected 0700). Directory may have been tampered with.`,
|
||||
);
|
||||
}
|
||||
const currentUid = process.getuid?.();
|
||||
if (currentUid !== undefined && dirStats.uid !== currentUid) {
|
||||
throw new Error(
|
||||
`Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` +
|
||||
`Potential security risk.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (dirError) {
|
||||
if ((dirError as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw dirError;
|
||||
}
|
||||
// Directory doesn't exist yet - native host will create it
|
||||
}
|
||||
}
|
||||
|
||||
const stats = await fsPromises.stat(socketPath);
|
||||
|
||||
if (!stats.isSocket()) {
|
||||
throw new Error(
|
||||
`[${serverName}] Path exists but it's not a socket: ${socketPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const mode = stats.mode & 0o777;
|
||||
if (mode !== 0o600) {
|
||||
throw new Error(
|
||||
`[${serverName}] Insecure socket permissions: ${mode.toString(
|
||||
8,
|
||||
)} (expected 0600). Socket may have been tampered with.`,
|
||||
);
|
||||
}
|
||||
|
||||
const currentUid = process.getuid?.();
|
||||
if (currentUid !== undefined && stats.uid !== currentUid) {
|
||||
throw new Error(
|
||||
`Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` +
|
||||
`Potential security risk.`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`[${serverName}] Socket security validation passed`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
logger.info(
|
||||
`[${serverName}] Socket not found, will be created by server`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpSocketClient(
|
||||
context: ClaudeForChromeContext,
|
||||
): McpSocketClient {
|
||||
return new McpSocketClient(context);
|
||||
}
|
||||
|
||||
export type { McpSocketClient };
|
||||
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
createMcpSocketClient,
|
||||
SocketConnectionError,
|
||||
} from "./mcpSocketClient.js";
|
||||
import type { McpSocketClient } from "./mcpSocketClient.js";
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Manages connections to multiple Chrome native host sockets (one per Chrome profile).
|
||||
* Routes tool calls to the correct socket based on tab ID.
|
||||
*
|
||||
* For `tabs_context_mcp`: queries all connected sockets and merges results.
|
||||
* For other tools: routes based on the `tabId` argument using a routing table
|
||||
* built from tabs_context_mcp responses.
|
||||
*/
|
||||
export class McpSocketPool {
|
||||
private clients: Map<string, McpSocketClient> = new Map();
|
||||
private tabRoutes: Map<number, string> = new Map();
|
||||
private context: ClaudeForChromeContext;
|
||||
private notificationHandler:
|
||||
| ((notification: { method: string; params?: Record<string, unknown> }) => void)
|
||||
| null = null;
|
||||
|
||||
constructor(context: ClaudeForChromeContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public setNotificationHandler(
|
||||
handler: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void,
|
||||
): void {
|
||||
this.notificationHandler = handler;
|
||||
for (const client of this.clients.values()) {
|
||||
client.setNotificationHandler(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available sockets and ensure at least one is connected.
|
||||
*/
|
||||
public async ensureConnected(): Promise<boolean> {
|
||||
const { logger, serverName } = this.context;
|
||||
|
||||
this.refreshClients();
|
||||
|
||||
// Try to connect any disconnected clients
|
||||
const connectPromises: Promise<boolean>[] = [];
|
||||
for (const client of this.clients.values()) {
|
||||
if (!client.isConnected()) {
|
||||
connectPromises.push(
|
||||
client.ensureConnected().catch(() => false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectPromises.length > 0) {
|
||||
await Promise.all(connectPromises);
|
||||
}
|
||||
|
||||
const connectedCount = this.getConnectedClients().length;
|
||||
if (connectedCount === 0) {
|
||||
logger.info(`[${serverName}] No connected sockets in pool`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool, routing to the correct socket based on tab ID.
|
||||
* For tabs_context_mcp, queries all sockets and merges results.
|
||||
*/
|
||||
public async callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
_permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown> {
|
||||
if (name === "tabs_context_mcp") {
|
||||
return this.callTabsContext(args);
|
||||
}
|
||||
|
||||
// Route by tabId if present
|
||||
const tabId = args.tabId as number | undefined;
|
||||
if (tabId !== undefined) {
|
||||
const socketPath = this.tabRoutes.get(tabId);
|
||||
if (socketPath) {
|
||||
const client = this.clients.get(socketPath);
|
||||
if (client?.isConnected()) {
|
||||
return client.callTool(name, args);
|
||||
}
|
||||
}
|
||||
// Tab route not found or client disconnected — fall through to any connected
|
||||
}
|
||||
|
||||
// Fallback: use first connected client
|
||||
const connected = this.getConnectedClients();
|
||||
if (connected.length === 0) {
|
||||
throw new SocketConnectionError(
|
||||
`[${this.context.serverName}] No connected sockets available`,
|
||||
);
|
||||
}
|
||||
return connected[0]!.callTool(name, args);
|
||||
}
|
||||
|
||||
public async setPermissionMode(
|
||||
mode: PermissionMode,
|
||||
allowedDomains?: string[],
|
||||
): Promise<void> {
|
||||
const connected = this.getConnectedClients();
|
||||
await Promise.all(
|
||||
connected.map((client) => client.setPermissionMode(mode, allowedDomains)),
|
||||
);
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.getConnectedClients().length > 0;
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
for (const client of this.clients.values()) {
|
||||
client.disconnect();
|
||||
}
|
||||
this.clients.clear();
|
||||
this.tabRoutes.clear();
|
||||
}
|
||||
|
||||
private getConnectedClients(): McpSocketClient[] {
|
||||
return [...this.clients.values()].filter((c) => c.isConnected());
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all connected sockets for tabs and merge results.
|
||||
* Updates the tab routing table.
|
||||
*/
|
||||
private async callTabsContext(
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const { logger, serverName } = this.context;
|
||||
const connected = this.getConnectedClients();
|
||||
|
||||
if (connected.length === 0) {
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] No connected sockets available`,
|
||||
);
|
||||
}
|
||||
|
||||
// If only one client, skip merging overhead
|
||||
if (connected.length === 1) {
|
||||
const result = await connected[0]!.callTool("tabs_context_mcp", args);
|
||||
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Query all connected clients in parallel
|
||||
const results = await Promise.allSettled(
|
||||
connected.map(async (client) => {
|
||||
const result = await client.callTool("tabs_context_mcp", args);
|
||||
const socketPath = this.getSocketPathForClient(client);
|
||||
return { result, socketPath };
|
||||
}),
|
||||
);
|
||||
|
||||
// Merge tab results
|
||||
const mergedTabs: unknown[] = [];
|
||||
this.tabRoutes.clear();
|
||||
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status !== "fulfilled") {
|
||||
logger.info(
|
||||
`[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { result, socketPath } = settledResult.value;
|
||||
this.updateTabRoutes(result, socketPath);
|
||||
|
||||
const tabs = this.extractTabs(result);
|
||||
if (tabs) {
|
||||
mergedTabs.push(...tabs);
|
||||
}
|
||||
}
|
||||
|
||||
// Return merged result in the same format as the extension response
|
||||
if (mergedTabs.length > 0) {
|
||||
const tabListText = mergedTabs
|
||||
.map((t) => {
|
||||
const tab = t as { tabId: number; title: string; url: string };
|
||||
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ availableTabs: mergedTabs }),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: return first successful result as-is
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status === "fulfilled") {
|
||||
return settledResult.value.result;
|
||||
}
|
||||
}
|
||||
|
||||
throw new SocketConnectionError(
|
||||
`[${serverName}] All sockets failed for tabs_context_mcp`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tab objects from a tool response to update routing table.
|
||||
*/
|
||||
private updateTabRoutes(result: unknown, socketPath: string): void {
|
||||
const tabs = this.extractTabs(result);
|
||||
if (!tabs) return;
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (typeof tab === "object" && tab !== null && "tabId" in tab) {
|
||||
const tabId = (tab as { tabId: number }).tabId;
|
||||
this.tabRoutes.set(tabId, socketPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractTabs(result: unknown): unknown[] | null {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
|
||||
// Response format: { result: { content: [{ type: "text", text: "{\"availableTabs\":[...],\"tabGroupId\":...}" }] } }
|
||||
const asResponse = result as {
|
||||
result?: { content?: Array<{ type: string; text?: string }> };
|
||||
};
|
||||
const content = asResponse.result?.content;
|
||||
if (!content || !Array.isArray(content)) return null;
|
||||
|
||||
for (const item of content) {
|
||||
if (item.type === "text" && item.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.text);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
// Handle { availableTabs: [...] } format
|
||||
if (parsed && Array.isArray(parsed.availableTabs)) {
|
||||
return parsed.availableTabs;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getSocketPathForClient(client: McpSocketClient): string {
|
||||
for (const [path, c] of this.clients.entries()) {
|
||||
if (c === client) return path;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for available sockets and create/remove clients as needed.
|
||||
*/
|
||||
private refreshClients(): void {
|
||||
const socketPaths = this.getAvailableSocketPaths();
|
||||
const { logger, serverName } = this.context;
|
||||
|
||||
// Add new clients for newly discovered sockets
|
||||
for (const path of socketPaths) {
|
||||
if (!this.clients.has(path)) {
|
||||
logger.info(`[${serverName}] Adding socket to pool: ${path}`);
|
||||
const clientContext: ClaudeForChromeContext = {
|
||||
...this.context,
|
||||
socketPath: path,
|
||||
getSocketPath: undefined,
|
||||
getSocketPaths: undefined,
|
||||
};
|
||||
const client = createMcpSocketClient(clientContext);
|
||||
client.disableAutoReconnect = true;
|
||||
if (this.notificationHandler) {
|
||||
client.setNotificationHandler(this.notificationHandler);
|
||||
}
|
||||
this.clients.set(path, client);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove clients for sockets that no longer exist
|
||||
for (const [path, client] of this.clients.entries()) {
|
||||
if (!socketPaths.includes(path)) {
|
||||
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`);
|
||||
client.disconnect();
|
||||
this.clients.delete(path);
|
||||
for (const [tabId, socketPath] of this.tabRoutes.entries()) {
|
||||
if (socketPath === path) {
|
||||
this.tabRoutes.delete(tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getAvailableSocketPaths(): string[] {
|
||||
return this.context.getSocketPaths?.() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpSocketPool(
|
||||
context: ClaudeForChromeContext,
|
||||
): McpSocketPool {
|
||||
return new McpSocketPool(context);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { SocketConnectionError } from "./mcpSocketClient.js";
|
||||
import type {
|
||||
ClaudeForChromeContext,
|
||||
PermissionMode,
|
||||
PermissionOverrides,
|
||||
SocketClient,
|
||||
} from "./types.js";
|
||||
|
||||
export const handleToolCall = async (
|
||||
context: ClaudeForChromeContext,
|
||||
socketClient: SocketClient,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<CallToolResult> => {
|
||||
// Handle permission mode changes locally (not forwarded to extension)
|
||||
if (name === "set_permission_mode") {
|
||||
return handleSetPermissionMode(socketClient, args);
|
||||
}
|
||||
|
||||
// Handle switch_browser outside the normal tool call flow (manages its own connection)
|
||||
if (name === "switch_browser") {
|
||||
return handleSwitchBrowser(context, socketClient);
|
||||
}
|
||||
|
||||
try {
|
||||
const isConnected = await socketClient.ensureConnected();
|
||||
|
||||
context.logger.silly(
|
||||
`[${context.serverName}] Server is connected: ${isConnected}. Received tool call: ${name} with args: ${JSON.stringify(args)}.`,
|
||||
);
|
||||
|
||||
if (isConnected) {
|
||||
return await handleToolCallConnected(
|
||||
context,
|
||||
socketClient,
|
||||
name,
|
||||
args,
|
||||
permissionOverrides,
|
||||
);
|
||||
}
|
||||
|
||||
return handleToolCallDisconnected(context);
|
||||
} catch (error) {
|
||||
context.logger.info(`[${context.serverName}] Error calling tool:`, error);
|
||||
|
||||
if (error instanceof SocketConnectionError) {
|
||||
return handleToolCallDisconnected(context);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error calling tool, please try again. : ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
async function handleToolCallConnected(
|
||||
context: ClaudeForChromeContext,
|
||||
socketClient: SocketClient,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<CallToolResult> {
|
||||
const response = await socketClient.callTool(name, args, permissionOverrides);
|
||||
|
||||
context.logger.silly(
|
||||
`[${context.serverName}] Received result from socket bridge: ${JSON.stringify(response)}`,
|
||||
);
|
||||
|
||||
if (response === null || response === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool execution completed" }],
|
||||
};
|
||||
}
|
||||
|
||||
// Response will have either result or error field
|
||||
const { result, error } = response as {
|
||||
result?: { content: unknown[] | string };
|
||||
error?: { content: unknown[] | string };
|
||||
};
|
||||
|
||||
// Determine which field has the content and whether it's an error
|
||||
const contentData = error || result;
|
||||
const isError = !!error;
|
||||
|
||||
if (!contentData) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool execution completed" }],
|
||||
};
|
||||
}
|
||||
|
||||
if (isError && isAuthenticationError(contentData.content)) {
|
||||
context.onAuthenticationError();
|
||||
}
|
||||
|
||||
const { content } = contentData;
|
||||
|
||||
if (content && Array.isArray(content)) {
|
||||
if (isError) {
|
||||
return {
|
||||
content: content.map((item: unknown) => {
|
||||
if (typeof item === "object" && item !== null && "type" in item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return { type: "text", text: String(item) };
|
||||
}),
|
||||
isError: true,
|
||||
} as CallToolResult;
|
||||
}
|
||||
|
||||
const convertedContent = content.map((item: unknown) => {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"type" in item &&
|
||||
"source" in item
|
||||
) {
|
||||
const typedItem = item;
|
||||
if (
|
||||
typedItem.type === "image" &&
|
||||
typeof typedItem.source === "object" &&
|
||||
typedItem.source !== null &&
|
||||
"data" in typedItem.source
|
||||
) {
|
||||
return {
|
||||
type: "image",
|
||||
data: typedItem.source.data,
|
||||
mimeType:
|
||||
"media_type" in typedItem.source
|
||||
? typedItem.source.media_type || "image/png"
|
||||
: "image/png",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item === "object" && item !== null && "type" in item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return { type: "text", text: String(item) };
|
||||
});
|
||||
|
||||
return {
|
||||
content: convertedContent,
|
||||
isError,
|
||||
} as CallToolResult;
|
||||
}
|
||||
|
||||
// Handle string content
|
||||
if (typeof content === "string") {
|
||||
return {
|
||||
content: [{ type: "text", text: content }],
|
||||
isError,
|
||||
} as CallToolResult;
|
||||
}
|
||||
|
||||
// Fallback for unexpected result format
|
||||
context.logger.warn(
|
||||
`[${context.serverName}] Unexpected result format from socket bridge`,
|
||||
response,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(response) }],
|
||||
isError,
|
||||
};
|
||||
}
|
||||
|
||||
function handleToolCallDisconnected(
|
||||
context: ClaudeForChromeContext,
|
||||
): CallToolResult {
|
||||
const text = context.onToolCallDisconnected();
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set_permission_mode tool call locally.
|
||||
* This is security-sensitive as it controls whether permission prompts are shown.
|
||||
*/
|
||||
async function handleSetPermissionMode(
|
||||
socketClient: SocketClient,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<CallToolResult> {
|
||||
// Validate permission mode at runtime
|
||||
const validModes = [
|
||||
"ask",
|
||||
"skip_all_permission_checks",
|
||||
"follow_a_plan",
|
||||
] as const;
|
||||
const mode = args.mode as string | undefined;
|
||||
const permissionMode: PermissionMode =
|
||||
mode && validModes.includes(mode as PermissionMode)
|
||||
? (mode as PermissionMode)
|
||||
: "ask";
|
||||
|
||||
if (socketClient.setPermissionMode) {
|
||||
await socketClient.setPermissionMode(
|
||||
permissionMode,
|
||||
args.allowed_domains as string[] | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Permission mode set to: ${permissionMode}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle switch_browser tool call. Broadcasts a pairing request and blocks
|
||||
* until a browser responds or timeout.
|
||||
*/
|
||||
async function handleSwitchBrowser(
|
||||
context: ClaudeForChromeContext,
|
||||
socketClient: SocketClient,
|
||||
): Promise<CallToolResult> {
|
||||
if (!context.bridgeConfig) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Browser switching is only available with bridge connections.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isConnected = await socketClient.ensureConnected();
|
||||
if (!isConnected) {
|
||||
return handleToolCallDisconnected(context);
|
||||
}
|
||||
|
||||
const result = (await socketClient.switchBrowser?.()) ?? null;
|
||||
|
||||
if (result === "no_other_browsers") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Connected to browser "${result.name}".` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the error content indicates an authentication issue
|
||||
*/
|
||||
function isAuthenticationError(content: unknown[] | string): boolean {
|
||||
const errorText = Array.isArray(content)
|
||||
? content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"text" in item &&
|
||||
typeof item.text === "string"
|
||||
) {
|
||||
return item.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join(" ")
|
||||
: String(content);
|
||||
|
||||
return errorText.toLowerCase().includes("re-authenticated");
|
||||
}
|
||||
134
claude-code-source/stubs/@ant/claude-for-chrome-mcp/src/types.ts
Normal file
134
claude-code-source/stubs/@ant/claude-for-chrome-mcp/src/types.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
export interface Logger {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
silly: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export type PermissionMode =
|
||||
| "ask"
|
||||
| "skip_all_permission_checks"
|
||||
| "follow_a_plan";
|
||||
|
||||
export interface BridgeConfig {
|
||||
/** Bridge WebSocket base URL (e.g., wss://bridge.claudeusercontent.com) */
|
||||
url: string;
|
||||
/** Returns the user's account UUID for the connection path */
|
||||
getUserId: () => Promise<string | undefined>;
|
||||
/** Returns a valid OAuth token for bridge authentication */
|
||||
getOAuthToken: () => Promise<string | undefined>;
|
||||
/** Optional dev user ID for local development (bypasses OAuth) */
|
||||
devUserId?: string;
|
||||
}
|
||||
|
||||
/** Metadata about a connected Chrome extension instance. */
|
||||
export interface ChromeExtensionInfo {
|
||||
deviceId: string;
|
||||
osPlatform?: string;
|
||||
connectedAt: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeForChromeContext {
|
||||
serverName: string;
|
||||
logger: Logger;
|
||||
socketPath: string;
|
||||
// Optional dynamic resolver for socket path. When provided, called on each
|
||||
// connection attempt to handle runtime conditions (e.g., TMPDIR mismatch).
|
||||
getSocketPath?: () => string;
|
||||
// Optional resolver returning all available socket paths (for multi-profile support).
|
||||
// When provided, a socket pool connects to all sockets and routes by tab ID.
|
||||
getSocketPaths?: () => string[];
|
||||
clientTypeId: string; // "desktop" | "claude-code"
|
||||
onToolCallDisconnected: () => string;
|
||||
onAuthenticationError: () => void;
|
||||
isDisabled?: () => boolean;
|
||||
/** Bridge WebSocket configuration. When provided, uses bridge instead of socket. */
|
||||
bridgeConfig?: BridgeConfig;
|
||||
/** If set, permission mode is sent to the extension immediately on bridge connection. */
|
||||
initialPermissionMode?: PermissionMode;
|
||||
/** Optional callback to track telemetry events for bridge connections */
|
||||
trackEvent?: <K extends string>(
|
||||
eventName: K,
|
||||
metadata: Record<string, unknown> | null,
|
||||
) => void;
|
||||
/** Called when user pairs with an extension via the browser pairing flow. */
|
||||
onExtensionPaired?: (deviceId: string, name: string) => void;
|
||||
/** Returns the previously paired deviceId, if any. */
|
||||
getPersistedDeviceId?: () => string | undefined;
|
||||
/** Called when a remote extension is auto-selected (only option available). */
|
||||
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Node's process.platform to the platform string reported by Chrome extensions
|
||||
* via navigator.userAgentData.platform.
|
||||
*/
|
||||
export function localPlatformLabel(): string {
|
||||
return process.platform === "darwin"
|
||||
? "macOS"
|
||||
: process.platform === "win32"
|
||||
? "Windows"
|
||||
: "Linux";
|
||||
}
|
||||
|
||||
/** Permission request forwarded from the extension to the desktop for user approval. */
|
||||
export interface BridgePermissionRequest {
|
||||
/** Links to the pending tool_call */
|
||||
toolUseId: string;
|
||||
/** Unique ID for this permission request */
|
||||
requestId: string;
|
||||
/** Tool type, e.g. "navigate", "click", "execute_javascript" */
|
||||
toolType: string;
|
||||
/** The URL/domain context */
|
||||
url: string;
|
||||
/** Additional action data (click coordinates, text, etc.) */
|
||||
actionData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Desktop response to a bridge permission request. */
|
||||
export interface BridgePermissionResponse {
|
||||
requestId: string;
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
/** Per-call permission overrides, allowing each session to use its own permission state. */
|
||||
export interface PermissionOverrides {
|
||||
permissionMode: PermissionMode;
|
||||
allowedDomains?: string[];
|
||||
/** Callback invoked when the extension requests user permission via the bridge. */
|
||||
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Shared interface for McpSocketClient and McpSocketPool */
|
||||
export interface SocketClient {
|
||||
ensureConnected(): Promise<boolean>;
|
||||
callTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
permissionOverrides?: PermissionOverrides,
|
||||
): Promise<unknown>;
|
||||
isConnected(): boolean;
|
||||
disconnect(): void;
|
||||
setNotificationHandler(
|
||||
handler: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void,
|
||||
): void;
|
||||
/** Set permission mode for the current session. Only effective on BridgeClient. */
|
||||
setPermissionMode?(
|
||||
mode: PermissionMode,
|
||||
allowedDomains?: string[],
|
||||
): Promise<void>;
|
||||
/** Switch to a different browser. Only available on BridgeClient. */
|
||||
switchBrowser?(): Promise<
|
||||
| {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
}
|
||||
| "no_other_browsers"
|
||||
| null
|
||||
>;
|
||||
}
|
||||
705
claude-code-source/stubs/@anthropic-ai/mcpb/dist/cli/init.js
vendored
Normal file
705
claude-code-source/stubs/@anthropic-ai/mcpb/dist/cli/init.js
vendored
Normal file
@@ -0,0 +1,705 @@
|
||||
import { confirm, input, select } from "@inquirer/prompts";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { basename, join, resolve } from "path";
|
||||
import { CURRENT_MANIFEST_VERSION } from "../schemas.js";
|
||||
export function readPackageJson(dirPath) {
|
||||
const packageJsonPath = join(dirPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
}
|
||||
catch (e) {
|
||||
// Ignore package.json parsing errors
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
export function getDefaultAuthorName(packageData) {
|
||||
if (typeof packageData.author === "string") {
|
||||
return packageData.author;
|
||||
}
|
||||
return packageData.author?.name || "";
|
||||
}
|
||||
export function getDefaultAuthorEmail(packageData) {
|
||||
if (typeof packageData.author === "object") {
|
||||
return packageData.author?.email || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
export function getDefaultAuthorUrl(packageData) {
|
||||
if (typeof packageData.author === "object") {
|
||||
return packageData.author?.url || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
export function getDefaultRepositoryUrl(packageData) {
|
||||
if (typeof packageData.repository === "string") {
|
||||
return packageData.repository;
|
||||
}
|
||||
return packageData.repository?.url || "";
|
||||
}
|
||||
export function getDefaultBasicInfo(packageData, resolvedPath) {
|
||||
const name = packageData.name || basename(resolvedPath);
|
||||
const authorName = getDefaultAuthorName(packageData) || "Unknown Author";
|
||||
const displayName = name;
|
||||
const version = packageData.version || "1.0.0";
|
||||
const description = packageData.description || "A MCPB bundle";
|
||||
return { name, authorName, displayName, version, description };
|
||||
}
|
||||
export function getDefaultAuthorInfo(packageData) {
|
||||
return {
|
||||
authorEmail: getDefaultAuthorEmail(packageData),
|
||||
authorUrl: getDefaultAuthorUrl(packageData),
|
||||
};
|
||||
}
|
||||
export function getDefaultServerConfig(packageData) {
|
||||
const serverType = "node";
|
||||
const entryPoint = getDefaultEntryPoint(serverType, packageData);
|
||||
const mcp_config = createMcpConfig(serverType, entryPoint);
|
||||
return { serverType, entryPoint, mcp_config };
|
||||
}
|
||||
export function getDefaultOptionalFields(packageData) {
|
||||
return {
|
||||
keywords: "",
|
||||
license: packageData.license || "MIT",
|
||||
repository: undefined,
|
||||
};
|
||||
}
|
||||
export function createMcpConfig(serverType, entryPoint) {
|
||||
switch (serverType) {
|
||||
case "node":
|
||||
return {
|
||||
command: "node",
|
||||
args: ["${__dirname}/" + entryPoint],
|
||||
env: {},
|
||||
};
|
||||
case "python":
|
||||
return {
|
||||
command: "python",
|
||||
args: ["${__dirname}/" + entryPoint],
|
||||
env: {
|
||||
PYTHONPATH: "${__dirname}/server/lib",
|
||||
},
|
||||
};
|
||||
case "binary":
|
||||
return {
|
||||
command: "${__dirname}/" + entryPoint,
|
||||
args: [],
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
export function getDefaultEntryPoint(serverType, packageData) {
|
||||
switch (serverType) {
|
||||
case "node":
|
||||
return packageData?.main || "server/index.js";
|
||||
case "python":
|
||||
return "server/main.py";
|
||||
case "binary":
|
||||
return "server/my-server";
|
||||
}
|
||||
}
|
||||
export async function promptBasicInfo(packageData, resolvedPath) {
|
||||
const defaultName = packageData.name || basename(resolvedPath);
|
||||
const name = await input({
|
||||
message: "Extension name:",
|
||||
default: defaultName,
|
||||
validate: (value) => value.trim().length > 0 || "Name is required",
|
||||
});
|
||||
const authorName = await input({
|
||||
message: "Author name:",
|
||||
default: getDefaultAuthorName(packageData),
|
||||
validate: (value) => value.trim().length > 0 || "Author name is required",
|
||||
});
|
||||
const displayName = await input({
|
||||
message: "Display name (optional):",
|
||||
default: name,
|
||||
});
|
||||
const version = await input({
|
||||
message: "Version:",
|
||||
default: packageData.version || "1.0.0",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return "Version is required";
|
||||
if (!/^\d+\.\d+\.\d+/.test(value)) {
|
||||
return "Version must follow semantic versioning (e.g., 1.0.0)";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const description = await input({
|
||||
message: "Description:",
|
||||
default: packageData.description || "",
|
||||
validate: (value) => value.trim().length > 0 || "Description is required",
|
||||
});
|
||||
return { name, authorName, displayName, version, description };
|
||||
}
|
||||
export async function promptAuthorInfo(packageData) {
|
||||
const authorEmail = await input({
|
||||
message: "Author email (optional):",
|
||||
default: getDefaultAuthorEmail(packageData),
|
||||
});
|
||||
const authorUrl = await input({
|
||||
message: "Author URL (optional):",
|
||||
default: getDefaultAuthorUrl(packageData),
|
||||
});
|
||||
return { authorEmail, authorUrl };
|
||||
}
|
||||
export async function promptServerConfig(packageData) {
|
||||
const serverType = (await select({
|
||||
message: "Server type:",
|
||||
choices: [
|
||||
{ name: "Node.js", value: "node" },
|
||||
{ name: "Python", value: "python" },
|
||||
{ name: "Binary", value: "binary" },
|
||||
],
|
||||
default: "node",
|
||||
}));
|
||||
const entryPoint = await input({
|
||||
message: "Entry point:",
|
||||
default: getDefaultEntryPoint(serverType, packageData),
|
||||
});
|
||||
const mcp_config = createMcpConfig(serverType, entryPoint);
|
||||
return { serverType, entryPoint, mcp_config };
|
||||
}
|
||||
export async function promptTools() {
|
||||
const addTools = await confirm({
|
||||
message: "Does your MCP Server provide tools you want to advertise (optional)?",
|
||||
default: true,
|
||||
});
|
||||
const tools = [];
|
||||
let toolsGenerated = false;
|
||||
if (addTools) {
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const toolName = await input({
|
||||
message: "Tool name:",
|
||||
validate: (value) => value.trim().length > 0 || "Tool name is required",
|
||||
});
|
||||
const toolDescription = await input({
|
||||
message: "Tool description (optional):",
|
||||
});
|
||||
tools.push({
|
||||
name: toolName,
|
||||
...(toolDescription ? { description: toolDescription } : {}),
|
||||
});
|
||||
addMore = await confirm({
|
||||
message: "Add another tool?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
// Ask about generated tools
|
||||
toolsGenerated = await confirm({
|
||||
message: "Does your server generate additional tools at runtime?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
return { tools, toolsGenerated };
|
||||
}
|
||||
export async function promptPrompts() {
|
||||
const addPrompts = await confirm({
|
||||
message: "Does your MCP Server provide prompts you want to advertise (optional)?",
|
||||
default: false,
|
||||
});
|
||||
const prompts = [];
|
||||
let promptsGenerated = false;
|
||||
if (addPrompts) {
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const promptName = await input({
|
||||
message: "Prompt name:",
|
||||
validate: (value) => value.trim().length > 0 || "Prompt name is required",
|
||||
});
|
||||
const promptDescription = await input({
|
||||
message: "Prompt description (optional):",
|
||||
});
|
||||
// Ask about arguments
|
||||
const hasArguments = await confirm({
|
||||
message: "Does this prompt have arguments?",
|
||||
default: false,
|
||||
});
|
||||
const argumentNames = [];
|
||||
if (hasArguments) {
|
||||
let addMoreArgs = true;
|
||||
while (addMoreArgs) {
|
||||
const argName = await input({
|
||||
message: "Argument name:",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return "Argument name is required";
|
||||
if (argumentNames.includes(value)) {
|
||||
return "Argument names must be unique";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
argumentNames.push(argName);
|
||||
addMoreArgs = await confirm({
|
||||
message: "Add another argument?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Prompt for the text template
|
||||
const promptText = await input({
|
||||
message: hasArguments
|
||||
? `Prompt text (use \${arguments.name} for arguments: ${argumentNames.join(", ")}):`
|
||||
: "Prompt text:",
|
||||
validate: (value) => value.trim().length > 0 || "Prompt text is required",
|
||||
});
|
||||
prompts.push({
|
||||
name: promptName,
|
||||
...(promptDescription ? { description: promptDescription } : {}),
|
||||
...(argumentNames.length > 0 ? { arguments: argumentNames } : {}),
|
||||
text: promptText,
|
||||
});
|
||||
addMore = await confirm({
|
||||
message: "Add another prompt?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
// Ask about generated prompts
|
||||
promptsGenerated = await confirm({
|
||||
message: "Does your server generate additional prompts at runtime?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
return { prompts, promptsGenerated };
|
||||
}
|
||||
export async function promptOptionalFields(packageData) {
|
||||
const keywords = await input({
|
||||
message: "Keywords (comma-separated, optional):",
|
||||
default: "",
|
||||
});
|
||||
const license = await input({
|
||||
message: "License:",
|
||||
default: packageData.license || "MIT",
|
||||
});
|
||||
const addRepository = await confirm({
|
||||
message: "Add repository information?",
|
||||
default: !!packageData.repository,
|
||||
});
|
||||
let repository;
|
||||
if (addRepository) {
|
||||
const repoUrl = await input({
|
||||
message: "Repository URL:",
|
||||
default: getDefaultRepositoryUrl(packageData),
|
||||
});
|
||||
if (repoUrl) {
|
||||
repository = {
|
||||
type: "git",
|
||||
url: repoUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { keywords, license, repository };
|
||||
}
|
||||
export async function promptLongDescription(description) {
|
||||
const hasLongDescription = await confirm({
|
||||
message: "Add a detailed long description?",
|
||||
default: false,
|
||||
});
|
||||
if (hasLongDescription) {
|
||||
const longDescription = await input({
|
||||
message: "Long description (supports basic markdown):",
|
||||
default: description,
|
||||
});
|
||||
return longDescription;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export async function promptUrls() {
|
||||
const homepage = await input({
|
||||
message: "Homepage URL (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return true;
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return "Must be a valid URL (e.g., https://example.com)";
|
||||
}
|
||||
},
|
||||
});
|
||||
const documentation = await input({
|
||||
message: "Documentation URL (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return true;
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return "Must be a valid URL";
|
||||
}
|
||||
},
|
||||
});
|
||||
const support = await input({
|
||||
message: "Support URL (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return true;
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return "Must be a valid URL";
|
||||
}
|
||||
},
|
||||
});
|
||||
return { homepage, documentation, support };
|
||||
}
|
||||
export async function promptVisualAssets() {
|
||||
const icon = await input({
|
||||
message: "Icon file path (optional, relative to manifest):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return true;
|
||||
if (value.includes(".."))
|
||||
return "Relative paths cannot include '..'";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const addScreenshots = await confirm({
|
||||
message: "Add screenshots?",
|
||||
default: false,
|
||||
});
|
||||
const screenshots = [];
|
||||
if (addScreenshots) {
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const screenshot = await input({
|
||||
message: "Screenshot file path (relative to manifest):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return "Screenshot path is required";
|
||||
if (value.includes(".."))
|
||||
return "Relative paths cannot include '..'";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
screenshots.push(screenshot);
|
||||
addMore = await confirm({
|
||||
message: "Add another screenshot?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { icon, screenshots };
|
||||
}
|
||||
export async function promptCompatibility(serverType) {
|
||||
const addCompatibility = await confirm({
|
||||
message: "Add compatibility constraints?",
|
||||
default: false,
|
||||
});
|
||||
if (!addCompatibility) {
|
||||
return undefined;
|
||||
}
|
||||
const addPlatforms = await confirm({
|
||||
message: "Specify supported platforms?",
|
||||
default: false,
|
||||
});
|
||||
let platforms;
|
||||
if (addPlatforms) {
|
||||
const selectedPlatforms = [];
|
||||
const supportsDarwin = await confirm({
|
||||
message: "Support macOS (darwin)?",
|
||||
default: true,
|
||||
});
|
||||
if (supportsDarwin)
|
||||
selectedPlatforms.push("darwin");
|
||||
const supportsWin32 = await confirm({
|
||||
message: "Support Windows (win32)?",
|
||||
default: true,
|
||||
});
|
||||
if (supportsWin32)
|
||||
selectedPlatforms.push("win32");
|
||||
const supportsLinux = await confirm({
|
||||
message: "Support Linux?",
|
||||
default: true,
|
||||
});
|
||||
if (supportsLinux)
|
||||
selectedPlatforms.push("linux");
|
||||
platforms = selectedPlatforms.length > 0 ? selectedPlatforms : undefined;
|
||||
}
|
||||
let runtimes;
|
||||
if (serverType !== "binary") {
|
||||
const addRuntimes = await confirm({
|
||||
message: "Specify runtime version constraints?",
|
||||
default: false,
|
||||
});
|
||||
if (addRuntimes) {
|
||||
if (serverType === "python") {
|
||||
const pythonVersion = await input({
|
||||
message: "Python version constraint (e.g., >=3.8,<4.0):",
|
||||
validate: (value) => value.trim().length > 0 || "Python version constraint is required",
|
||||
});
|
||||
runtimes = { python: pythonVersion };
|
||||
}
|
||||
else if (serverType === "node") {
|
||||
const nodeVersion = await input({
|
||||
message: "Node.js version constraint (e.g., >=16.0.0):",
|
||||
validate: (value) => value.trim().length > 0 || "Node.js version constraint is required",
|
||||
});
|
||||
runtimes = { node: nodeVersion };
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(platforms ? { platforms } : {}),
|
||||
...(runtimes ? { runtimes } : {}),
|
||||
};
|
||||
}
|
||||
export async function promptUserConfig() {
|
||||
const addUserConfig = await confirm({
|
||||
message: "Add user-configurable options?",
|
||||
default: false,
|
||||
});
|
||||
if (!addUserConfig) {
|
||||
return {};
|
||||
}
|
||||
const userConfig = {};
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const optionKey = await input({
|
||||
message: "Configuration option key (unique identifier):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return "Key is required";
|
||||
if (userConfig[value])
|
||||
return "Key must be unique";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const optionType = (await select({
|
||||
message: "Option type:",
|
||||
choices: [
|
||||
{ name: "String", value: "string" },
|
||||
{ name: "Number", value: "number" },
|
||||
{ name: "Boolean", value: "boolean" },
|
||||
{ name: "Directory", value: "directory" },
|
||||
{ name: "File", value: "file" },
|
||||
],
|
||||
}));
|
||||
const optionTitle = await input({
|
||||
message: "Option title (human-readable name):",
|
||||
validate: (value) => value.trim().length > 0 || "Title is required",
|
||||
});
|
||||
const optionDescription = await input({
|
||||
message: "Option description:",
|
||||
validate: (value) => value.trim().length > 0 || "Description is required",
|
||||
});
|
||||
const optionRequired = await confirm({
|
||||
message: "Is this option required?",
|
||||
default: false,
|
||||
});
|
||||
const optionSensitive = await confirm({
|
||||
message: "Is this option sensitive (like a password)?",
|
||||
default: false,
|
||||
});
|
||||
// Build the option object
|
||||
const option = {
|
||||
type: optionType,
|
||||
title: optionTitle,
|
||||
description: optionDescription,
|
||||
required: optionRequired,
|
||||
sensitive: optionSensitive,
|
||||
};
|
||||
// Add default value if not required
|
||||
if (!optionRequired) {
|
||||
let defaultValue;
|
||||
if (optionType === "boolean") {
|
||||
defaultValue = await confirm({
|
||||
message: "Default value:",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
else if (optionType === "number") {
|
||||
const defaultStr = await input({
|
||||
message: "Default value (number):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return true;
|
||||
return !isNaN(Number(value)) || "Must be a valid number";
|
||||
},
|
||||
});
|
||||
defaultValue = defaultStr ? Number(defaultStr) : undefined;
|
||||
}
|
||||
else {
|
||||
defaultValue = await input({
|
||||
message: "Default value (optional):",
|
||||
});
|
||||
}
|
||||
if (defaultValue !== undefined && defaultValue !== "") {
|
||||
option.default = defaultValue;
|
||||
}
|
||||
}
|
||||
// Add constraints for number types
|
||||
if (optionType === "number") {
|
||||
const addConstraints = await confirm({
|
||||
message: "Add min/max constraints?",
|
||||
default: false,
|
||||
});
|
||||
if (addConstraints) {
|
||||
const min = await input({
|
||||
message: "Minimum value (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return true;
|
||||
return !isNaN(Number(value)) || "Must be a valid number";
|
||||
},
|
||||
});
|
||||
const max = await input({
|
||||
message: "Maximum value (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim())
|
||||
return true;
|
||||
return !isNaN(Number(value)) || "Must be a valid number";
|
||||
},
|
||||
});
|
||||
if (min)
|
||||
option.min = Number(min);
|
||||
if (max)
|
||||
option.max = Number(max);
|
||||
}
|
||||
}
|
||||
userConfig[optionKey] = option;
|
||||
addMore = await confirm({
|
||||
message: "Add another configuration option?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
return userConfig;
|
||||
}
|
||||
export function buildManifest(basicInfo, longDescription, authorInfo, urls, visualAssets, serverConfig, tools, toolsGenerated, prompts, promptsGenerated, compatibility, userConfig, optionalFields) {
|
||||
const { name, displayName, version, description, authorName } = basicInfo;
|
||||
const { authorEmail, authorUrl } = authorInfo;
|
||||
const { serverType, entryPoint, mcp_config } = serverConfig;
|
||||
const { keywords, license, repository } = optionalFields;
|
||||
return {
|
||||
manifest_version: CURRENT_MANIFEST_VERSION,
|
||||
name,
|
||||
...(displayName && displayName !== name
|
||||
? { display_name: displayName }
|
||||
: {}),
|
||||
version,
|
||||
description,
|
||||
...(longDescription ? { long_description: longDescription } : {}),
|
||||
author: {
|
||||
name: authorName,
|
||||
...(authorEmail ? { email: authorEmail } : {}),
|
||||
...(authorUrl ? { url: authorUrl } : {}),
|
||||
},
|
||||
...(urls.homepage ? { homepage: urls.homepage } : {}),
|
||||
...(urls.documentation ? { documentation: urls.documentation } : {}),
|
||||
...(urls.support ? { support: urls.support } : {}),
|
||||
...(visualAssets.icon ? { icon: visualAssets.icon } : {}),
|
||||
...(visualAssets.screenshots.length > 0
|
||||
? { screenshots: visualAssets.screenshots }
|
||||
: {}),
|
||||
server: {
|
||||
type: serverType,
|
||||
entry_point: entryPoint,
|
||||
mcp_config,
|
||||
},
|
||||
...(tools.length > 0 ? { tools } : {}),
|
||||
...(toolsGenerated ? { tools_generated: true } : {}),
|
||||
...(prompts.length > 0 ? { prompts } : {}),
|
||||
...(promptsGenerated ? { prompts_generated: true } : {}),
|
||||
...(compatibility ? { compatibility } : {}),
|
||||
...(Object.keys(userConfig).length > 0 ? { user_config: userConfig } : {}),
|
||||
...(keywords
|
||||
? {
|
||||
keywords: keywords
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k),
|
||||
}
|
||||
: {}),
|
||||
...(license ? { license } : {}),
|
||||
...(repository ? { repository } : {}),
|
||||
};
|
||||
}
|
||||
export function printNextSteps() {
|
||||
console.log("\nNext steps:");
|
||||
console.log(`1. Ensure all your production dependencies are in this directory`);
|
||||
console.log(`2. Run 'mcpb pack' to create your .mcpb file`);
|
||||
}
|
||||
export async function initExtension(targetPath = process.cwd(), nonInteractive = false) {
|
||||
const resolvedPath = resolve(targetPath);
|
||||
const manifestPath = join(resolvedPath, "manifest.json");
|
||||
if (existsSync(manifestPath)) {
|
||||
if (nonInteractive) {
|
||||
console.log("manifest.json already exists. Use --force to overwrite in non-interactive mode.");
|
||||
return false;
|
||||
}
|
||||
const overwrite = await confirm({
|
||||
message: "manifest.json already exists. Overwrite?",
|
||||
default: false,
|
||||
});
|
||||
if (!overwrite) {
|
||||
console.log("Cancelled");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!nonInteractive) {
|
||||
console.log("This utility will help you create a manifest.json file for your MCPB bundle.");
|
||||
console.log("Press ^C at any time to quit.\n");
|
||||
}
|
||||
else {
|
||||
console.log("Creating manifest.json with default values...");
|
||||
}
|
||||
try {
|
||||
const packageData = readPackageJson(resolvedPath);
|
||||
// Prompt for all information or use defaults
|
||||
const basicInfo = nonInteractive
|
||||
? getDefaultBasicInfo(packageData, resolvedPath)
|
||||
: await promptBasicInfo(packageData, resolvedPath);
|
||||
const longDescription = nonInteractive
|
||||
? undefined
|
||||
: await promptLongDescription(basicInfo.description);
|
||||
const authorInfo = nonInteractive
|
||||
? getDefaultAuthorInfo(packageData)
|
||||
: await promptAuthorInfo(packageData);
|
||||
const urls = nonInteractive
|
||||
? { homepage: "", documentation: "", support: "" }
|
||||
: await promptUrls();
|
||||
const visualAssets = nonInteractive
|
||||
? { icon: "", screenshots: [] }
|
||||
: await promptVisualAssets();
|
||||
const serverConfig = nonInteractive
|
||||
? getDefaultServerConfig(packageData)
|
||||
: await promptServerConfig(packageData);
|
||||
const toolsData = nonInteractive
|
||||
? { tools: [], toolsGenerated: false }
|
||||
: await promptTools();
|
||||
const promptsData = nonInteractive
|
||||
? { prompts: [], promptsGenerated: false }
|
||||
: await promptPrompts();
|
||||
const compatibility = nonInteractive
|
||||
? undefined
|
||||
: await promptCompatibility(serverConfig.serverType);
|
||||
const userConfig = nonInteractive ? {} : await promptUserConfig();
|
||||
const optionalFields = nonInteractive
|
||||
? getDefaultOptionalFields(packageData)
|
||||
: await promptOptionalFields(packageData);
|
||||
// Build manifest
|
||||
const manifest = buildManifest(basicInfo, longDescription, authorInfo, urls, visualAssets, serverConfig, toolsData.tools, toolsData.toolsGenerated, promptsData.prompts, promptsData.promptsGenerated, compatibility, userConfig, optionalFields);
|
||||
// Write manifest
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
||||
console.log(`\nCreated manifest.json at ${manifestPath}`);
|
||||
printNextSteps();
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message.includes("User force closed")) {
|
||||
console.log("\nCancelled");
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
200
claude-code-source/stubs/@anthropic-ai/mcpb/dist/cli/pack.js
vendored
Normal file
200
claude-code-source/stubs/@anthropic-ai/mcpb/dist/cli/pack.js
vendored
Normal file
@@ -0,0 +1,200 @@
|
||||
import { confirm } from "@inquirer/prompts";
|
||||
import { createHash } from "crypto";
|
||||
import { zipSync } from "fflate";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from "fs";
|
||||
import { basename, join, relative, resolve, sep } from "path";
|
||||
import { getAllFilesWithCount, readMcpbIgnorePatterns } from "../node/files.js";
|
||||
import { validateManifest } from "../node/validate.js";
|
||||
import { CURRENT_MANIFEST_VERSION, McpbManifestSchema } from "../schemas.js";
|
||||
import { getLogger } from "../shared/log.js";
|
||||
import { initExtension } from "./init.js";
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`;
|
||||
}
|
||||
else if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}kB`;
|
||||
}
|
||||
else {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
}
|
||||
function sanitizeNameForFilename(name) {
|
||||
// Replace spaces with hyphens
|
||||
// Remove or replace characters that are problematic in filenames
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/[^a-z0-9-_.]/g, "") // Keep only alphanumeric, hyphens, underscores, and dots
|
||||
.replace(/-+/g, "-") // Replace multiple hyphens with single hyphen
|
||||
.replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens
|
||||
.substring(0, 100); // Limit length to 100 characters
|
||||
}
|
||||
export async function packExtension({ extensionPath, outputPath, silent, }) {
|
||||
const resolvedPath = resolve(extensionPath);
|
||||
const logger = getLogger({ silent });
|
||||
// Check if directory exists
|
||||
if (!existsSync(resolvedPath) || !statSync(resolvedPath).isDirectory()) {
|
||||
logger.error(`ERROR: Directory not found: ${extensionPath}`);
|
||||
return false;
|
||||
}
|
||||
// Check if manifest exists
|
||||
const manifestPath = join(resolvedPath, "manifest.json");
|
||||
if (!existsSync(manifestPath)) {
|
||||
logger.log(`No manifest.json found in ${extensionPath}`);
|
||||
const shouldInit = await confirm({
|
||||
message: "Would you like to create a manifest.json file?",
|
||||
default: true,
|
||||
});
|
||||
if (shouldInit) {
|
||||
const success = await initExtension(extensionPath);
|
||||
if (!success) {
|
||||
logger.error("ERROR: Failed to create manifest");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
logger.error("ERROR: Cannot pack extension without manifest.json");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validate manifest first
|
||||
logger.log("Validating manifest...");
|
||||
if (!validateManifest(manifestPath)) {
|
||||
logger.error("ERROR: Cannot pack extension with invalid manifest");
|
||||
return false;
|
||||
}
|
||||
// Read and parse manifest
|
||||
let manifest;
|
||||
try {
|
||||
const manifestContent = readFileSync(manifestPath, "utf-8");
|
||||
const manifestData = JSON.parse(manifestContent);
|
||||
manifest = McpbManifestSchema.parse(manifestData);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error("ERROR: Failed to parse manifest.json");
|
||||
if (error instanceof Error) {
|
||||
logger.error(` ${error.message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const manifestVersion = manifest.manifest_version || manifest.dxt_version;
|
||||
if (manifestVersion !== CURRENT_MANIFEST_VERSION) {
|
||||
logger.error(`ERROR: Manifest version mismatch. Expected "${CURRENT_MANIFEST_VERSION}", found "${manifestVersion}"`);
|
||||
logger.error(` Please update the manifest_version in your manifest.json to "${CURRENT_MANIFEST_VERSION}"`);
|
||||
return false;
|
||||
}
|
||||
// Determine output path
|
||||
const extensionName = basename(resolvedPath);
|
||||
const finalOutputPath = outputPath
|
||||
? resolve(outputPath)
|
||||
: resolve(`${extensionName}.mcpb`);
|
||||
// Ensure output directory exists
|
||||
const outputDir = join(finalOutputPath, "..");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
try {
|
||||
// Read .mcpbignore patterns if present
|
||||
const mcpbIgnorePatterns = readMcpbIgnorePatterns(resolvedPath);
|
||||
// Get all files in the extension directory
|
||||
const { files, ignoredCount } = getAllFilesWithCount(resolvedPath, resolvedPath, {}, mcpbIgnorePatterns);
|
||||
// Print package header
|
||||
logger.log(`\n📦 ${manifest.name}@${manifest.version}`);
|
||||
// Print file list
|
||||
logger.log("Archive Contents");
|
||||
const fileEntries = Object.entries(files);
|
||||
let totalUnpackedSize = 0;
|
||||
// Sort files for consistent output
|
||||
fileEntries.sort(([a], [b]) => a.localeCompare(b));
|
||||
// Group files by directory for deep nesting
|
||||
const directoryGroups = new Map();
|
||||
const shallowFiles = [];
|
||||
for (const [filePath, fileData] of fileEntries) {
|
||||
const relPath = relative(resolvedPath, filePath);
|
||||
const content = fileData.data;
|
||||
const size = typeof content === "string"
|
||||
? Buffer.byteLength(content, "utf8")
|
||||
: content.length;
|
||||
totalUnpackedSize += size;
|
||||
// Check if file is deeply nested (3+ levels)
|
||||
const parts = relPath.split(sep);
|
||||
if (parts.length > 3) {
|
||||
// Group by the first 3 directory levels
|
||||
const groupKey = parts.slice(0, 3).join("/");
|
||||
if (!directoryGroups.has(groupKey)) {
|
||||
directoryGroups.set(groupKey, { files: [], totalSize: 0 });
|
||||
}
|
||||
const group = directoryGroups.get(groupKey);
|
||||
group.files.push(relPath);
|
||||
group.totalSize += size;
|
||||
}
|
||||
else {
|
||||
shallowFiles.push({ path: relPath, size });
|
||||
}
|
||||
}
|
||||
// Print shallow files first
|
||||
for (const { path, size } of shallowFiles) {
|
||||
logger.log(`${formatFileSize(size).padStart(8)} ${path}`);
|
||||
}
|
||||
// Print grouped directories
|
||||
for (const [dir, { files, totalSize }] of directoryGroups) {
|
||||
if (files.length === 1) {
|
||||
// If only one file in the group, print it normally
|
||||
const filePath = files[0];
|
||||
const fileSize = totalSize;
|
||||
logger.log(`${formatFileSize(fileSize).padStart(8)} ${filePath}`);
|
||||
}
|
||||
else {
|
||||
// Print directory summary
|
||||
logger.log(`${formatFileSize(totalSize).padStart(8)} ${dir}/ [and ${files.length} more files]`);
|
||||
}
|
||||
}
|
||||
// Create zip with preserved file permissions
|
||||
const zipFiles = {};
|
||||
const isUnix = process.platform !== "win32";
|
||||
for (const [filePath, fileData] of Object.entries(files)) {
|
||||
if (isUnix) {
|
||||
// Set external file attributes to preserve Unix permissions
|
||||
// The mode needs to be shifted to the upper 16 bits for ZIP format
|
||||
zipFiles[filePath] = [
|
||||
fileData.data,
|
||||
{ os: 3, attrs: (fileData.mode & 0o777) << 16 },
|
||||
];
|
||||
}
|
||||
else {
|
||||
// On Windows, use default ZIP attributes (no Unix permissions)
|
||||
zipFiles[filePath] = fileData.data;
|
||||
}
|
||||
}
|
||||
const zipData = zipSync(zipFiles, {
|
||||
level: 9, // Maximum compression
|
||||
mtime: new Date(),
|
||||
});
|
||||
// Write zip file
|
||||
writeFileSync(finalOutputPath, zipData);
|
||||
// Calculate SHA sum
|
||||
const shasum = createHash("sha1").update(zipData).digest("hex");
|
||||
// Print archive details
|
||||
const sanitizedName = sanitizeNameForFilename(manifest.name);
|
||||
const archiveName = `${sanitizedName}-${manifest.version}.mcpb`;
|
||||
logger.log("\nArchive Details");
|
||||
logger.log(`name: ${manifest.name}`);
|
||||
logger.log(`version: ${manifest.version}`);
|
||||
logger.log(`filename: ${archiveName}`);
|
||||
logger.log(`package size: ${formatFileSize(zipData.length)}`);
|
||||
logger.log(`unpacked size: ${formatFileSize(totalUnpackedSize)}`);
|
||||
logger.log(`shasum: ${shasum}`);
|
||||
logger.log(`total files: ${fileEntries.length}`);
|
||||
logger.log(`ignored (.mcpbignore) files: ${ignoredCount}`);
|
||||
logger.log(`\nOutput: ${finalOutputPath}`);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(`ERROR: Archive error: ${error.message}`);
|
||||
}
|
||||
else {
|
||||
logger.error("ERROR: Unknown archive error occurred");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
101
claude-code-source/stubs/@anthropic-ai/mcpb/dist/cli/unpack.js
vendored
Normal file
101
claude-code-source/stubs/@anthropic-ai/mcpb/dist/cli/unpack.js
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
import { unzipSync } from "fflate";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "fs";
|
||||
import { join, resolve, sep } from "path";
|
||||
import { extractSignatureBlock } from "../node/sign.js";
|
||||
import { getLogger } from "../shared/log.js";
|
||||
export async function unpackExtension({ mcpbPath, outputDir, silent, }) {
|
||||
const logger = getLogger({ silent });
|
||||
const resolvedMcpbPath = resolve(mcpbPath);
|
||||
if (!existsSync(resolvedMcpbPath)) {
|
||||
logger.error(`ERROR: MCPB file not found: ${mcpbPath}`);
|
||||
return false;
|
||||
}
|
||||
const finalOutputDir = outputDir ? resolve(outputDir) : process.cwd();
|
||||
if (!existsSync(finalOutputDir)) {
|
||||
mkdirSync(finalOutputDir, { recursive: true });
|
||||
}
|
||||
try {
|
||||
const fileContent = readFileSync(resolvedMcpbPath);
|
||||
const { originalContent } = extractSignatureBlock(fileContent);
|
||||
// Parse file attributes from ZIP central directory
|
||||
const fileAttributes = new Map();
|
||||
const isUnix = process.platform !== "win32";
|
||||
if (isUnix) {
|
||||
// Parse ZIP central directory to extract file attributes
|
||||
const zipBuffer = originalContent;
|
||||
// Find end of central directory record
|
||||
let eocdOffset = -1;
|
||||
for (let i = zipBuffer.length - 22; i >= 0; i--) {
|
||||
if (zipBuffer.readUInt32LE(i) === 0x06054b50) {
|
||||
eocdOffset = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocdOffset !== -1) {
|
||||
const centralDirOffset = zipBuffer.readUInt32LE(eocdOffset + 16);
|
||||
const centralDirEntries = zipBuffer.readUInt16LE(eocdOffset + 8);
|
||||
let offset = centralDirOffset;
|
||||
for (let i = 0; i < centralDirEntries; i++) {
|
||||
if (zipBuffer.readUInt32LE(offset) === 0x02014b50) {
|
||||
const externalAttrs = zipBuffer.readUInt32LE(offset + 38);
|
||||
const filenameLength = zipBuffer.readUInt16LE(offset + 28);
|
||||
const filename = zipBuffer.toString("utf8", offset + 46, offset + 46 + filenameLength);
|
||||
// Extract Unix permissions from external attributes (upper 16 bits)
|
||||
const mode = (externalAttrs >> 16) & 0o777;
|
||||
if (mode > 0) {
|
||||
fileAttributes.set(filename, mode);
|
||||
}
|
||||
const extraFieldLength = zipBuffer.readUInt16LE(offset + 30);
|
||||
const commentLength = zipBuffer.readUInt16LE(offset + 32);
|
||||
offset += 46 + filenameLength + extraFieldLength + commentLength;
|
||||
}
|
||||
else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const decompressed = unzipSync(originalContent);
|
||||
for (const relativePath in decompressed) {
|
||||
if (Object.prototype.hasOwnProperty.call(decompressed, relativePath)) {
|
||||
const data = decompressed[relativePath];
|
||||
const fullPath = join(finalOutputDir, relativePath);
|
||||
// Prevent zip slip attacks by validating the resolved path
|
||||
const normalizedPath = resolve(fullPath);
|
||||
const normalizedOutputDir = resolve(finalOutputDir);
|
||||
if (!normalizedPath.startsWith(normalizedOutputDir + sep) &&
|
||||
normalizedPath !== normalizedOutputDir) {
|
||||
throw new Error(`Path traversal attempt detected: ${relativePath}`);
|
||||
}
|
||||
const dir = join(fullPath, "..");
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(fullPath, data);
|
||||
// Restore Unix file permissions if available
|
||||
if (isUnix && fileAttributes.has(relativePath)) {
|
||||
try {
|
||||
const mode = fileAttributes.get(relativePath);
|
||||
if (mode !== undefined) {
|
||||
chmodSync(fullPath, mode);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// Silently ignore permission errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.log(`Extension unpacked successfully to ${finalOutputDir}`);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(`ERROR: Failed to unpack extension: ${error.message}`);
|
||||
}
|
||||
else {
|
||||
logger.error("ERROR: An unknown error occurred during unpacking.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
10
claude-code-source/stubs/@anthropic-ai/mcpb/dist/index.js
vendored
Normal file
10
claude-code-source/stubs/@anthropic-ai/mcpb/dist/index.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// Default export includes everything (backward compatibility)
|
||||
export * from "./cli/init.js";
|
||||
export * from "./cli/pack.js";
|
||||
export * from "./cli/unpack.js";
|
||||
export * from "./node/files.js";
|
||||
export * from "./node/sign.js";
|
||||
export * from "./node/validate.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./shared/config.js";
|
||||
export * from "./types.js";
|
||||
115
claude-code-source/stubs/@anthropic-ai/mcpb/dist/node/files.js
vendored
Normal file
115
claude-code-source/stubs/@anthropic-ai/mcpb/dist/node/files.js
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
||||
import ignore from "ignore";
|
||||
import { join, relative, sep } from "path";
|
||||
// Files/patterns to exclude from the package
|
||||
export const EXCLUDE_PATTERNS = [
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
".gitignore",
|
||||
".git",
|
||||
".mcpbignore",
|
||||
"*.log",
|
||||
".env*",
|
||||
".npm",
|
||||
".npmrc",
|
||||
".yarnrc",
|
||||
".yarn",
|
||||
".eslintrc",
|
||||
".editorconfig",
|
||||
".prettierrc",
|
||||
".prettierignore",
|
||||
".eslintignore",
|
||||
".nycrc",
|
||||
".babelrc",
|
||||
".pnp.*",
|
||||
"node_modules/.cache",
|
||||
"node_modules/.bin",
|
||||
"*.map",
|
||||
".env.local",
|
||||
".env.*.local",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"*.mcpb",
|
||||
"*.d.ts",
|
||||
"*.tsbuildinfo",
|
||||
"tsconfig.json",
|
||||
];
|
||||
/**
|
||||
* Read and parse .mcpbignore file patterns
|
||||
*/
|
||||
export function readMcpbIgnorePatterns(baseDir) {
|
||||
const mcpbIgnorePath = join(baseDir, ".mcpbignore");
|
||||
if (!existsSync(mcpbIgnorePath)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(mcpbIgnorePath, "utf-8");
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Warning: Could not read .mcpbignore file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function buildIgnoreChecker(additionalPatterns) {
|
||||
return ignore().add(EXCLUDE_PATTERNS).add(additionalPatterns);
|
||||
}
|
||||
/**
|
||||
* Used for testing, calls the same methods as the other ignore checks
|
||||
*/
|
||||
export function shouldExclude(filePath, additionalPatterns = []) {
|
||||
return buildIgnoreChecker(additionalPatterns).ignores(filePath);
|
||||
}
|
||||
export function getAllFiles(dirPath, baseDir = dirPath, fileList = {}, additionalPatterns = []) {
|
||||
const files = readdirSync(dirPath);
|
||||
const ignoreChecker = buildIgnoreChecker(additionalPatterns);
|
||||
for (const file of files) {
|
||||
const filePath = join(dirPath, file);
|
||||
const relativePath = relative(baseDir, filePath);
|
||||
if (ignoreChecker.ignores(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
const stat = statSync(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
getAllFiles(filePath, baseDir, fileList, additionalPatterns);
|
||||
}
|
||||
else {
|
||||
// Use forward slashes in zip file paths
|
||||
const zipPath = relativePath.split(sep).join("/");
|
||||
fileList[zipPath] = readFileSync(filePath);
|
||||
}
|
||||
}
|
||||
return fileList;
|
||||
}
|
||||
export function getAllFilesWithCount(dirPath, baseDir = dirPath, fileList = {}, additionalPatterns = [], ignoredCount = 0) {
|
||||
const files = readdirSync(dirPath);
|
||||
const ignoreChecker = buildIgnoreChecker(additionalPatterns);
|
||||
for (const file of files) {
|
||||
const filePath = join(dirPath, file);
|
||||
const relativePath = relative(baseDir, filePath);
|
||||
if (ignoreChecker.ignores(relativePath)) {
|
||||
ignoredCount++;
|
||||
continue;
|
||||
}
|
||||
const stat = statSync(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
const result = getAllFilesWithCount(filePath, baseDir, fileList, additionalPatterns, ignoredCount);
|
||||
ignoredCount = result.ignoredCount;
|
||||
}
|
||||
else {
|
||||
// Use forward slashes in zip file paths
|
||||
const zipPath = relativePath.split(sep).join("/");
|
||||
fileList[zipPath] = {
|
||||
data: readFileSync(filePath),
|
||||
mode: stat.mode,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { files: fileList, ignoredCount };
|
||||
}
|
||||
333
claude-code-source/stubs/@anthropic-ai/mcpb/dist/node/sign.js
vendored
Normal file
333
claude-code-source/stubs/@anthropic-ai/mcpb/dist/node/sign.js
vendored
Normal file
@@ -0,0 +1,333 @@
|
||||
import { execFile } from "child_process";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { mkdtemp, rm, writeFile } from "fs/promises";
|
||||
import forge from "node-forge";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { promisify } from "util";
|
||||
// Signature block markers
|
||||
const SIGNATURE_HEADER = "MCPB_SIG_V1";
|
||||
const SIGNATURE_FOOTER = "MCPB_SIG_END";
|
||||
const execFileAsync = promisify(execFile);
|
||||
/**
|
||||
* Signs a MCPB file with the given certificate and private key using PKCS#7
|
||||
*
|
||||
* @param mcpbPath Path to the MCPB file to sign
|
||||
* @param certPath Path to the certificate file (PEM format)
|
||||
* @param keyPath Path to the private key file (PEM format)
|
||||
* @param intermediates Optional array of intermediate certificate paths
|
||||
*/
|
||||
export function signMcpbFile(mcpbPath, certPath, keyPath, intermediates) {
|
||||
// Read the original MCPB file
|
||||
const mcpbContent = readFileSync(mcpbPath);
|
||||
// Read certificate and key
|
||||
const certificatePem = readFileSync(certPath, "utf-8");
|
||||
const privateKeyPem = readFileSync(keyPath, "utf-8");
|
||||
// Read intermediate certificates if provided
|
||||
const intermediatePems = intermediates?.map((path) => readFileSync(path, "utf-8"));
|
||||
// Create PKCS#7 signed data
|
||||
const p7 = forge.pkcs7.createSignedData();
|
||||
p7.content = forge.util.createBuffer(mcpbContent);
|
||||
// Parse and add certificates
|
||||
const signingCert = forge.pki.certificateFromPem(certificatePem);
|
||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||
p7.addCertificate(signingCert);
|
||||
// Add intermediate certificates
|
||||
if (intermediatePems) {
|
||||
for (const pem of intermediatePems) {
|
||||
p7.addCertificate(forge.pki.certificateFromPem(pem));
|
||||
}
|
||||
}
|
||||
// Add signer
|
||||
p7.addSigner({
|
||||
key: privateKey,
|
||||
certificate: signingCert,
|
||||
digestAlgorithm: forge.pki.oids.sha256,
|
||||
authenticatedAttributes: [
|
||||
{
|
||||
type: forge.pki.oids.contentType,
|
||||
value: forge.pki.oids.data,
|
||||
},
|
||||
{
|
||||
type: forge.pki.oids.messageDigest,
|
||||
// Value will be auto-populated
|
||||
},
|
||||
{
|
||||
type: forge.pki.oids.signingTime,
|
||||
// Value will be auto-populated with current time
|
||||
},
|
||||
],
|
||||
});
|
||||
// Sign with detached signature
|
||||
p7.sign({ detached: true });
|
||||
// Convert to DER format
|
||||
const asn1 = forge.asn1.toDer(p7.toAsn1());
|
||||
const pkcs7Signature = Buffer.from(asn1.getBytes(), "binary");
|
||||
// Create signature block with PKCS#7 data
|
||||
const signatureBlock = createSignatureBlock(pkcs7Signature);
|
||||
// Append signature block to MCPB file
|
||||
const signedContent = Buffer.concat([mcpbContent, signatureBlock]);
|
||||
writeFileSync(mcpbPath, signedContent);
|
||||
}
|
||||
/**
|
||||
* Verifies a signed MCPB file using OS certificate store
|
||||
*
|
||||
* @param mcpbPath Path to the signed MCPB file
|
||||
* @returns Signature information including verification status
|
||||
*/
|
||||
export async function verifyMcpbFile(mcpbPath) {
|
||||
try {
|
||||
const fileContent = readFileSync(mcpbPath);
|
||||
// Find and extract signature block
|
||||
const { originalContent, pkcs7Signature } = extractSignatureBlock(fileContent);
|
||||
if (!pkcs7Signature) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
// Parse PKCS#7 signature
|
||||
const asn1 = forge.asn1.fromDer(pkcs7Signature.toString("binary"));
|
||||
const p7Message = forge.pkcs7.messageFromAsn1(asn1);
|
||||
// Verify it's signed data and cast to correct type
|
||||
if (!("type" in p7Message) ||
|
||||
p7Message.type !== forge.pki.oids.signedData) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
// Now we know it's PkcsSignedData. The types are incorrect, so we'll
|
||||
// fix them there
|
||||
const p7 = p7Message;
|
||||
// Extract certificates from PKCS#7
|
||||
const certificates = p7.certificates || [];
|
||||
if (certificates.length === 0) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
// Get the signing certificate (first one)
|
||||
const signingCert = certificates[0];
|
||||
// Verify PKCS#7 signature
|
||||
const contentBuf = forge.util.createBuffer(originalContent);
|
||||
try {
|
||||
p7.verify({ authenticatedAttributes: true });
|
||||
// Also verify the content matches
|
||||
const signerInfos = p7.signerInfos;
|
||||
const signerInfo = signerInfos?.[0];
|
||||
if (signerInfo) {
|
||||
const md = forge.md.sha256.create();
|
||||
md.update(contentBuf.getBytes());
|
||||
const digest = md.digest().getBytes();
|
||||
// Find the message digest attribute
|
||||
let messageDigest = null;
|
||||
for (const attr of signerInfo.authenticatedAttributes) {
|
||||
if (attr.type === forge.pki.oids.messageDigest) {
|
||||
messageDigest = attr.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!messageDigest || messageDigest !== digest) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
// Convert forge certificate to PEM for OS verification
|
||||
const certPem = forge.pki.certificateToPem(signingCert);
|
||||
const intermediatePems = certificates
|
||||
.slice(1)
|
||||
.map((cert) => Buffer.from(forge.pki.certificateToPem(cert)));
|
||||
// Verify certificate chain against OS trust store
|
||||
const chainValid = await verifyCertificateChain(Buffer.from(certPem), intermediatePems);
|
||||
if (!chainValid) {
|
||||
// Signature is valid but certificate is not trusted
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
// Extract certificate info
|
||||
const isSelfSigned = signingCert.issuer.getField("CN")?.value ===
|
||||
signingCert.subject.getField("CN")?.value;
|
||||
return {
|
||||
status: isSelfSigned ? "self-signed" : "signed",
|
||||
publisher: signingCert.subject.getField("CN")?.value || "Unknown",
|
||||
issuer: signingCert.issuer.getField("CN")?.value || "Unknown",
|
||||
valid_from: signingCert.validity.notBefore.toISOString(),
|
||||
valid_to: signingCert.validity.notAfter.toISOString(),
|
||||
fingerprint: forge.md.sha256
|
||||
.create()
|
||||
.update(forge.asn1.toDer(forge.pki.certificateToAsn1(signingCert)).getBytes())
|
||||
.digest()
|
||||
.toHex(),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to verify MCPB file: ${error}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Creates a signature block buffer with PKCS#7 signature
|
||||
*/
|
||||
function createSignatureBlock(pkcs7Signature) {
|
||||
const parts = [];
|
||||
// Header
|
||||
parts.push(Buffer.from(SIGNATURE_HEADER, "utf-8"));
|
||||
// PKCS#7 signature length and data
|
||||
const sigLengthBuffer = Buffer.alloc(4);
|
||||
sigLengthBuffer.writeUInt32LE(pkcs7Signature.length, 0);
|
||||
parts.push(sigLengthBuffer);
|
||||
parts.push(pkcs7Signature);
|
||||
// Footer
|
||||
parts.push(Buffer.from(SIGNATURE_FOOTER, "utf-8"));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
/**
|
||||
* Extracts the signature block from a signed MCPB file
|
||||
*/
|
||||
export function extractSignatureBlock(fileContent) {
|
||||
// Look for signature footer at the end
|
||||
const footerBytes = Buffer.from(SIGNATURE_FOOTER, "utf-8");
|
||||
const footerIndex = fileContent.lastIndexOf(footerBytes);
|
||||
if (footerIndex === -1) {
|
||||
return { originalContent: fileContent };
|
||||
}
|
||||
// Look for signature header before footer
|
||||
const headerBytes = Buffer.from(SIGNATURE_HEADER, "utf-8");
|
||||
let headerIndex = -1;
|
||||
// Search backwards from footer
|
||||
for (let i = footerIndex - 1; i >= 0; i--) {
|
||||
if (fileContent.slice(i, i + headerBytes.length).equals(headerBytes)) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerIndex === -1) {
|
||||
return { originalContent: fileContent };
|
||||
}
|
||||
// Extract original content (everything before signature block)
|
||||
const originalContent = fileContent.slice(0, headerIndex);
|
||||
// Parse signature block
|
||||
let offset = headerIndex + headerBytes.length;
|
||||
try {
|
||||
// Read PKCS#7 signature length
|
||||
const sigLength = fileContent.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
// Read PKCS#7 signature
|
||||
const pkcs7Signature = fileContent.slice(offset, offset + sigLength);
|
||||
return {
|
||||
originalContent,
|
||||
pkcs7Signature,
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return { originalContent: fileContent };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Verifies certificate chain against OS trust store
|
||||
*/
|
||||
export async function verifyCertificateChain(certificate, intermediates) {
|
||||
let tempDir = null;
|
||||
try {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "mcpb-verify-"));
|
||||
const certChainPath = join(tempDir, "chain.pem");
|
||||
const certChain = [certificate, ...(intermediates || [])].join("\n");
|
||||
await writeFile(certChainPath, certChain);
|
||||
// Platform-specific verification
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
await execFileAsync("security", [
|
||||
"verify-cert",
|
||||
"-c",
|
||||
certChainPath,
|
||||
"-p",
|
||||
"codeSign",
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (process.platform === "win32") {
|
||||
const psCommand = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$certCollection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
|
||||
$certCollection.Import('${certChainPath}')
|
||||
|
||||
if ($certCollection.Count -eq 0) {
|
||||
Write-Error 'No certificates found'
|
||||
exit 1
|
||||
}
|
||||
|
||||
$leafCert = $certCollection[0]
|
||||
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
|
||||
|
||||
# Enable revocation checking
|
||||
$chain.ChainPolicy.RevocationMode = 'Online'
|
||||
$chain.ChainPolicy.RevocationFlag = 'EntireChain'
|
||||
$chain.ChainPolicy.UrlRetrievalTimeout = New-TimeSpan -Seconds 30
|
||||
|
||||
# Add code signing application policy
|
||||
$codeSignOid = New-Object System.Security.Cryptography.Oid '1.3.6.1.5.5.7.3.3'
|
||||
$chain.ChainPolicy.ApplicationPolicy.Add($codeSignOid)
|
||||
|
||||
# Add intermediate certificates to extra store
|
||||
for ($i = 1; $i -lt $certCollection.Count; $i++) {
|
||||
[void]$chain.ChainPolicy.ExtraStore.Add($certCollection[$i])
|
||||
}
|
||||
|
||||
# Build and validate chain
|
||||
$result = $chain.Build($leafCert)
|
||||
|
||||
if ($result) {
|
||||
'Valid'
|
||||
} else {
|
||||
$chain.ChainStatus | ForEach-Object {
|
||||
Write-Error "$($_.Status): $($_.StatusInformation)"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
`.trim();
|
||||
const { stdout } = await execFileAsync("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
psCommand,
|
||||
]);
|
||||
return stdout.includes("Valid");
|
||||
}
|
||||
else {
|
||||
// Linux: Use openssl
|
||||
try {
|
||||
await execFileAsync("openssl", [
|
||||
"verify",
|
||||
"-purpose",
|
||||
"codesigning",
|
||||
"-CApath",
|
||||
"/etc/ssl/certs",
|
||||
certChainPath,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return false;
|
||||
}
|
||||
finally {
|
||||
if (tempDir) {
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Removes signature from a MCPB file
|
||||
*/
|
||||
export function unsignMcpbFile(mcpbPath) {
|
||||
const fileContent = readFileSync(mcpbPath);
|
||||
const { originalContent } = extractSignatureBlock(fileContent);
|
||||
writeFileSync(mcpbPath, originalContent);
|
||||
}
|
||||
124
claude-code-source/stubs/@anthropic-ai/mcpb/dist/node/validate.js
vendored
Normal file
124
claude-code-source/stubs/@anthropic-ai/mcpb/dist/node/validate.js
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import { DestroyerOfModules } from "galactus";
|
||||
import * as os from "os";
|
||||
import { join, resolve } from "path";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { unpackExtension } from "../cli/unpack.js";
|
||||
import { McpbManifestSchema } from "../schemas.js";
|
||||
import { McpbManifestSchema as LooseMcpbManifestSchema } from "../schemas-loose.js";
|
||||
export function validateManifest(inputPath) {
|
||||
try {
|
||||
const resolvedPath = resolve(inputPath);
|
||||
let manifestPath = resolvedPath;
|
||||
// If input is a directory, look for manifest.json inside it
|
||||
if (existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
|
||||
manifestPath = join(resolvedPath, "manifest.json");
|
||||
}
|
||||
const manifestContent = readFileSync(manifestPath, "utf-8");
|
||||
const manifestData = JSON.parse(manifestContent);
|
||||
const result = McpbManifestSchema.safeParse(manifestData);
|
||||
if (result.success) {
|
||||
console.log("Manifest schema validation passes!");
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
console.log("ERROR: Manifest validation failed:\n");
|
||||
result.error.issues.forEach((issue) => {
|
||||
const path = issue.path.join(".");
|
||||
console.log(` - ${path ? `${path}: ` : ""}${issue.message}`);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("ENOENT")) {
|
||||
console.error(`ERROR: File not found: ${inputPath}`);
|
||||
if (existsSync(resolve(inputPath)) &&
|
||||
statSync(resolve(inputPath)).isDirectory()) {
|
||||
console.error(` (No manifest.json found in directory)`);
|
||||
}
|
||||
}
|
||||
else if (error.message.includes("JSON")) {
|
||||
console.error(`ERROR: Invalid JSON in manifest file: ${error.message}`);
|
||||
}
|
||||
else {
|
||||
console.error(`ERROR: Error reading manifest: ${error.message}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error("ERROR: Unknown error occurred");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export async function cleanMcpb(inputPath) {
|
||||
const tmpDir = await fs.mkdtemp(resolve(os.tmpdir(), "mcpb-clean-"));
|
||||
const mcpbPath = resolve(tmpDir, "in.mcpb");
|
||||
const unpackPath = resolve(tmpDir, "out");
|
||||
console.log(" -- Cleaning MCPB...");
|
||||
try {
|
||||
await fs.copyFile(inputPath, mcpbPath);
|
||||
console.log(" -- Unpacking MCPB...");
|
||||
await unpackExtension({ mcpbPath, silent: true, outputDir: unpackPath });
|
||||
const manifestPath = resolve(unpackPath, "manifest.json");
|
||||
const originalManifest = await fs.readFile(manifestPath, "utf-8");
|
||||
const manifestData = JSON.parse(originalManifest);
|
||||
const result = LooseMcpbManifestSchema.safeParse(manifestData);
|
||||
if (!result.success) {
|
||||
throw new Error(`Unrecoverable manifest issues, please run "mcpb validate"`);
|
||||
}
|
||||
await fs.writeFile(manifestPath, JSON.stringify(result.data, null, 2));
|
||||
if (originalManifest.trim() !==
|
||||
(await fs.readFile(manifestPath, "utf8")).trim()) {
|
||||
console.log(" -- Update manifest to be valid per MCPB schema");
|
||||
}
|
||||
else {
|
||||
console.log(" -- Manifest already valid per MCPB schema");
|
||||
}
|
||||
const nodeModulesPath = resolve(unpackPath, "node_modules");
|
||||
if (existsSync(nodeModulesPath)) {
|
||||
console.log(" -- node_modules found, deleting development dependencies");
|
||||
const destroyer = new DestroyerOfModules({
|
||||
rootDirectory: unpackPath,
|
||||
});
|
||||
try {
|
||||
await destroyer.destroy();
|
||||
}
|
||||
catch (error) {
|
||||
// If modules have already been deleted in a previous clean, the walker
|
||||
// will fail when it can't find required dependencies. This is expected
|
||||
// and safe to ignore.
|
||||
if (error instanceof Error &&
|
||||
error.message.includes("Failed to locate module")) {
|
||||
console.log(" -- Some modules already removed, skipping remaining cleanup");
|
||||
}
|
||||
else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
console.log(" -- Removed development dependencies from node_modules");
|
||||
}
|
||||
else {
|
||||
console.log(" -- No node_modules, not pruning");
|
||||
}
|
||||
const before = await fs.stat(inputPath);
|
||||
const { packExtension } = await import("../cli/pack.js");
|
||||
await packExtension({
|
||||
extensionPath: unpackPath,
|
||||
outputPath: inputPath,
|
||||
silent: true,
|
||||
});
|
||||
const after = await fs.stat(inputPath);
|
||||
console.log("\nClean Complete:");
|
||||
console.log("Before:", prettyBytes(before.size));
|
||||
console.log("After:", prettyBytes(after.size));
|
||||
}
|
||||
finally {
|
||||
await fs.rm(tmpDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
105
claude-code-source/stubs/@anthropic-ai/mcpb/dist/schemas-loose.js
vendored
Normal file
105
claude-code-source/stubs/@anthropic-ai/mcpb/dist/schemas-loose.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
import * as z from "zod";
|
||||
export const McpServerConfigSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
export const McpbManifestAuthorSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
url: z.string().url().optional(),
|
||||
});
|
||||
export const McpbManifestRepositorySchema = z.object({
|
||||
type: z.string(),
|
||||
url: z.string().url(),
|
||||
});
|
||||
export const McpbManifestPlatformOverrideSchema = McpServerConfigSchema.partial();
|
||||
export const McpbManifestMcpConfigSchema = McpServerConfigSchema.extend({
|
||||
platform_overrides: z
|
||||
.record(z.string(), McpbManifestPlatformOverrideSchema)
|
||||
.optional(),
|
||||
});
|
||||
export const McpbManifestServerSchema = z.object({
|
||||
type: z.enum(["python", "node", "binary"]),
|
||||
entry_point: z.string(),
|
||||
mcp_config: McpbManifestMcpConfigSchema,
|
||||
});
|
||||
export const McpbManifestCompatibilitySchema = z
|
||||
.object({
|
||||
claude_desktop: z.string().optional(),
|
||||
platforms: z.array(z.enum(["darwin", "win32", "linux"])).optional(),
|
||||
runtimes: z
|
||||
.object({
|
||||
python: z.string().optional(),
|
||||
node: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const McpbManifestToolSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
export const McpbManifestPromptSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
arguments: z.array(z.string()).optional(),
|
||||
text: z.string(),
|
||||
});
|
||||
export const McpbUserConfigurationOptionSchema = z.object({
|
||||
type: z.enum(["string", "number", "boolean", "directory", "file"]),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
required: z.boolean().optional(),
|
||||
default: z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.optional(),
|
||||
multiple: z.boolean().optional(),
|
||||
sensitive: z.boolean().optional(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
});
|
||||
export const McpbUserConfigValuesSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]));
|
||||
export const McpbManifestSchema = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
dxt_version: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("@deprecated Use manifest_version instead"),
|
||||
manifest_version: z.string().optional(),
|
||||
name: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
version: z.string(),
|
||||
description: z.string(),
|
||||
long_description: z.string().optional(),
|
||||
author: McpbManifestAuthorSchema,
|
||||
repository: McpbManifestRepositorySchema.optional(),
|
||||
homepage: z.string().url().optional(),
|
||||
documentation: z.string().url().optional(),
|
||||
support: z.string().url().optional(),
|
||||
icon: z.string().optional(),
|
||||
screenshots: z.array(z.string()).optional(),
|
||||
server: McpbManifestServerSchema,
|
||||
tools: z.array(McpbManifestToolSchema).optional(),
|
||||
tools_generated: z.boolean().optional(),
|
||||
prompts: z.array(McpbManifestPromptSchema).optional(),
|
||||
prompts_generated: z.boolean().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
license: z.string().optional(),
|
||||
compatibility: McpbManifestCompatibilitySchema.optional(),
|
||||
user_config: z
|
||||
.record(z.string(), McpbUserConfigurationOptionSchema)
|
||||
.optional(),
|
||||
})
|
||||
.refine((data) => !!(data.dxt_version || data.manifest_version), {
|
||||
message: "Either 'dxt_version' (deprecated) or 'manifest_version' must be provided",
|
||||
});
|
||||
export const McpbSignatureInfoSchema = z.object({
|
||||
status: z.enum(["signed", "unsigned", "self-signed"]),
|
||||
publisher: z.string().optional(),
|
||||
issuer: z.string().optional(),
|
||||
valid_from: z.string().optional(),
|
||||
valid_to: z.string().optional(),
|
||||
fingerprint: z.string().optional(),
|
||||
});
|
||||
107
claude-code-source/stubs/@anthropic-ai/mcpb/dist/schemas.js
vendored
Normal file
107
claude-code-source/stubs/@anthropic-ai/mcpb/dist/schemas.js
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as z from "zod";
|
||||
export const CURRENT_MANIFEST_VERSION = "0.2";
|
||||
export const McpServerConfigSchema = z.strictObject({
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
export const McpbManifestAuthorSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
url: z.string().url().optional(),
|
||||
});
|
||||
export const McpbManifestRepositorySchema = z.strictObject({
|
||||
type: z.string(),
|
||||
url: z.string().url(),
|
||||
});
|
||||
export const McpbManifestPlatformOverrideSchema = McpServerConfigSchema.partial();
|
||||
export const McpbManifestMcpConfigSchema = McpServerConfigSchema.extend({
|
||||
platform_overrides: z
|
||||
.record(z.string(), McpbManifestPlatformOverrideSchema)
|
||||
.optional(),
|
||||
});
|
||||
export const McpbManifestServerSchema = z.strictObject({
|
||||
type: z.enum(["python", "node", "binary"]),
|
||||
entry_point: z.string(),
|
||||
mcp_config: McpbManifestMcpConfigSchema,
|
||||
});
|
||||
export const McpbManifestCompatibilitySchema = z
|
||||
.strictObject({
|
||||
claude_desktop: z.string().optional(),
|
||||
platforms: z.array(z.enum(["darwin", "win32", "linux"])).optional(),
|
||||
runtimes: z
|
||||
.strictObject({
|
||||
python: z.string().optional(),
|
||||
node: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export const McpbManifestToolSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
export const McpbManifestPromptSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
arguments: z.array(z.string()).optional(),
|
||||
text: z.string(),
|
||||
});
|
||||
export const McpbUserConfigurationOptionSchema = z.strictObject({
|
||||
type: z.enum(["string", "number", "boolean", "directory", "file"]),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
required: z.boolean().optional(),
|
||||
default: z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.optional(),
|
||||
multiple: z.boolean().optional(),
|
||||
sensitive: z.boolean().optional(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
});
|
||||
export const McpbUserConfigValuesSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]));
|
||||
export const McpbManifestSchema = z
|
||||
.strictObject({
|
||||
$schema: z.string().optional(),
|
||||
dxt_version: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("@deprecated Use manifest_version instead"),
|
||||
manifest_version: z.string().optional(),
|
||||
name: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
version: z.string(),
|
||||
description: z.string(),
|
||||
long_description: z.string().optional(),
|
||||
author: McpbManifestAuthorSchema,
|
||||
repository: McpbManifestRepositorySchema.optional(),
|
||||
homepage: z.string().url().optional(),
|
||||
documentation: z.string().url().optional(),
|
||||
support: z.string().url().optional(),
|
||||
icon: z.string().optional(),
|
||||
screenshots: z.array(z.string()).optional(),
|
||||
server: McpbManifestServerSchema,
|
||||
tools: z.array(McpbManifestToolSchema).optional(),
|
||||
tools_generated: z.boolean().optional(),
|
||||
prompts: z.array(McpbManifestPromptSchema).optional(),
|
||||
prompts_generated: z.boolean().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
license: z.string().optional(),
|
||||
privacy_policies: z.array(z.string()).optional(),
|
||||
compatibility: McpbManifestCompatibilitySchema.optional(),
|
||||
user_config: z
|
||||
.record(z.string(), McpbUserConfigurationOptionSchema)
|
||||
.optional(),
|
||||
})
|
||||
.refine((data) => !!(data.dxt_version || data.manifest_version), {
|
||||
message: "Either 'dxt_version' (deprecated) or 'manifest_version' must be provided",
|
||||
});
|
||||
export const McpbSignatureInfoSchema = z.strictObject({
|
||||
status: z.enum(["signed", "unsigned", "self-signed"]),
|
||||
publisher: z.string().optional(),
|
||||
issuer: z.string().optional(),
|
||||
valid_from: z.string().optional(),
|
||||
valid_to: z.string().optional(),
|
||||
fingerprint: z.string().optional(),
|
||||
});
|
||||
157
claude-code-source/stubs/@anthropic-ai/mcpb/dist/shared/config.js
vendored
Normal file
157
claude-code-source/stubs/@anthropic-ai/mcpb/dist/shared/config.js
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* This file contains utility functions for handling MCPB configuration,
|
||||
* including variable replacement and MCP server configuration generation.
|
||||
*/
|
||||
/**
|
||||
* Recursively replaces variables in any value. Handles strings, arrays, and objects.
|
||||
*
|
||||
* @param value The value to process
|
||||
* @param variables Object containing variable replacements
|
||||
* @returns The processed value with all variables replaced
|
||||
*/
|
||||
export function replaceVariables(value, variables) {
|
||||
if (typeof value === "string") {
|
||||
let result = value;
|
||||
// Replace all variables in the string
|
||||
for (const [key, replacement] of Object.entries(variables)) {
|
||||
const pattern = new RegExp(`\\$\\{${key}\\}`, "g");
|
||||
// Check if this pattern actually exists in the string
|
||||
if (result.match(pattern)) {
|
||||
if (Array.isArray(replacement)) {
|
||||
console.warn(`Cannot replace ${key} with array value in string context: "${value}"`, { key, replacement });
|
||||
}
|
||||
else {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
else if (Array.isArray(value)) {
|
||||
// For arrays, we need to handle special case of array expansion
|
||||
const result = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === "string" &&
|
||||
item.match(/^\$\{user_config\.[^}]+\}$/)) {
|
||||
// This is a user config variable that might expand to multiple values
|
||||
const varName = item.match(/^\$\{([^}]+)\}$/)?.[1];
|
||||
if (varName && variables[varName]) {
|
||||
const replacement = variables[varName];
|
||||
if (Array.isArray(replacement)) {
|
||||
// Expand array inline
|
||||
result.push(...replacement);
|
||||
}
|
||||
else {
|
||||
result.push(replacement);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Variable not found, keep original
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Recursively process non-variable items
|
||||
result.push(replaceVariables(item, variables));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
else if (value && typeof value === "object") {
|
||||
const result = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
result[key] = replaceVariables(val, variables);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export async function getMcpConfigForManifest(options) {
|
||||
const { manifest, extensionPath, systemDirs, userConfig, pathSeparator, logger, } = options;
|
||||
const baseConfig = manifest.server?.mcp_config;
|
||||
if (!baseConfig) {
|
||||
return undefined;
|
||||
}
|
||||
let result = {
|
||||
...baseConfig,
|
||||
};
|
||||
if (baseConfig.platform_overrides) {
|
||||
if (process.platform in baseConfig.platform_overrides) {
|
||||
const platformConfig = baseConfig.platform_overrides[process.platform];
|
||||
result.command = platformConfig.command || result.command;
|
||||
result.args = platformConfig.args || result.args;
|
||||
result.env = platformConfig.env || result.env;
|
||||
}
|
||||
}
|
||||
// Check if required configuration is missing
|
||||
if (hasRequiredConfigMissing({ manifest, userConfig })) {
|
||||
logger?.warn(`Extension ${manifest.name} has missing required configuration, skipping MCP config`);
|
||||
return undefined;
|
||||
}
|
||||
const variables = {
|
||||
__dirname: extensionPath,
|
||||
pathSeparator,
|
||||
"/": pathSeparator,
|
||||
...systemDirs,
|
||||
};
|
||||
// Build merged configuration from defaults and user settings
|
||||
const mergedConfig = {};
|
||||
// First, add defaults from manifest
|
||||
if (manifest.user_config) {
|
||||
for (const [key, configOption] of Object.entries(manifest.user_config)) {
|
||||
if (configOption.default !== undefined) {
|
||||
mergedConfig[key] = configOption.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then, override with user settings
|
||||
if (userConfig) {
|
||||
Object.assign(mergedConfig, userConfig);
|
||||
}
|
||||
// Add merged configuration variables for substitution
|
||||
for (const [key, value] of Object.entries(mergedConfig)) {
|
||||
// Convert user config to the format expected by variable substitution
|
||||
const userConfigKey = `user_config.${key}`;
|
||||
if (Array.isArray(value)) {
|
||||
// Keep arrays as arrays for proper expansion
|
||||
variables[userConfigKey] = value.map(String);
|
||||
}
|
||||
else if (typeof value === "boolean") {
|
||||
// Convert booleans to "true"/"false" strings as per spec
|
||||
variables[userConfigKey] = value ? "true" : "false";
|
||||
}
|
||||
else {
|
||||
// Convert other types to strings
|
||||
variables[userConfigKey] = String(value);
|
||||
}
|
||||
}
|
||||
// Replace all variables in the config
|
||||
result = replaceVariables(result, variables);
|
||||
return result;
|
||||
}
|
||||
function isInvalidSingleValue(value) {
|
||||
return value === undefined || value === null || value === "";
|
||||
}
|
||||
/**
|
||||
* Check if an extension has missing required configuration
|
||||
* @param manifest The extension manifest
|
||||
* @param userConfig The user configuration
|
||||
* @returns true if required configuration is missing
|
||||
*/
|
||||
export function hasRequiredConfigMissing({ manifest, userConfig, }) {
|
||||
if (!manifest.user_config) {
|
||||
return false;
|
||||
}
|
||||
const config = userConfig || {};
|
||||
for (const [key, configOption] of Object.entries(manifest.user_config)) {
|
||||
if (configOption.required) {
|
||||
const value = config[key];
|
||||
if (isInvalidSingleValue(value) ||
|
||||
(Array.isArray(value) &&
|
||||
(value.length === 0 || value.some(isInvalidSingleValue)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
29
claude-code-source/stubs/@anthropic-ai/mcpb/dist/shared/log.js
vendored
Normal file
29
claude-code-source/stubs/@anthropic-ai/mcpb/dist/shared/log.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
export function getLogger({ silent = false } = {}) {
|
||||
return {
|
||||
log: (...args) => {
|
||||
if (!silent) {
|
||||
console.log(...args);
|
||||
}
|
||||
},
|
||||
error: (...args) => {
|
||||
if (!silent) {
|
||||
console.error(...args);
|
||||
}
|
||||
},
|
||||
warn: (...args) => {
|
||||
if (!silent) {
|
||||
console.warn(...args);
|
||||
}
|
||||
},
|
||||
info: (...args) => {
|
||||
if (!silent) {
|
||||
console.info(...args);
|
||||
}
|
||||
},
|
||||
debug: (...args) => {
|
||||
if (!silent) {
|
||||
console.debug(...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
1
claude-code-source/stubs/@anthropic-ai/mcpb/index.js
Normal file
1
claude-code-source/stubs/@anthropic-ai/mcpb/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export function getMcpConfigForManifest() { return null }
|
||||
1
claude-code-source/stubs/@anthropic-ai/mcpb/package.json
Normal file
1
claude-code-source/stubs/@anthropic-ai/mcpb/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"@anthropic-ai/mcpb","version":"1.0.0","type":"module","main":"index.js","exports":{".":"./index.js"}}
|
||||
9
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/index.js
vendored
Normal file
9
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/index.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// Library exports
|
||||
export { SandboxManager } from './sandbox/sandbox-manager.js';
|
||||
export { SandboxViolationStore } from './sandbox/sandbox-violation-store.js';
|
||||
export { SandboxRuntimeConfigSchema, NetworkConfigSchema, FilesystemConfigSchema, IgnoreViolationsConfigSchema, RipgrepConfigSchema, } from './sandbox/sandbox-config.js';
|
||||
// Utility functions
|
||||
export { getDefaultWritePaths } from './sandbox/sandbox-utils.js';
|
||||
// Platform utilities
|
||||
export { getWslVersion } from './utils/platform.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
263
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.js
vendored
Normal file
263
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/generate-seccomp-filter.js
vendored
Normal file
@@ -0,0 +1,263 @@
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
// Cache for path lookups (key: explicit path or empty string, value: resolved path or null)
|
||||
const bpfPathCache = new Map();
|
||||
const applySeccompPathCache = new Map();
|
||||
// Cache for global npm paths (computed once per process)
|
||||
let cachedGlobalNpmPaths = null;
|
||||
/**
|
||||
* Get paths to check for globally installed @anthropic-ai/sandbox-runtime package.
|
||||
* This is used as a fallback when the binaries aren't bundled (e.g., native builds).
|
||||
*/
|
||||
function getGlobalNpmPaths() {
|
||||
if (cachedGlobalNpmPaths)
|
||||
return cachedGlobalNpmPaths;
|
||||
const paths = [];
|
||||
// Try to get the actual global npm root
|
||||
try {
|
||||
const npmRoot = execSync('npm root -g', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
if (npmRoot) {
|
||||
paths.push(join(npmRoot, '@anthropic-ai', 'sandbox-runtime'));
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// npm not available or failed
|
||||
}
|
||||
// Common global npm locations as fallbacks
|
||||
const home = homedir();
|
||||
paths.push(
|
||||
// npm global (Linux/macOS)
|
||||
join('/usr', 'lib', 'node_modules', '@anthropic-ai', 'sandbox-runtime'), join('/usr', 'local', 'lib', 'node_modules', '@anthropic-ai', 'sandbox-runtime'),
|
||||
// npm global with prefix (common on macOS with homebrew)
|
||||
join('/opt', 'homebrew', 'lib', 'node_modules', '@anthropic-ai', 'sandbox-runtime'),
|
||||
// User-local npm global
|
||||
join(home, '.npm', 'lib', 'node_modules', '@anthropic-ai', 'sandbox-runtime'), join(home, '.npm-global', 'lib', 'node_modules', '@anthropic-ai', 'sandbox-runtime'));
|
||||
cachedGlobalNpmPaths = paths;
|
||||
return paths;
|
||||
}
|
||||
/**
|
||||
* Map Node.js process.arch to our vendor directory architecture names
|
||||
* Returns null for unsupported architectures
|
||||
*/
|
||||
function getVendorArchitecture() {
|
||||
const arch = process.arch;
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
case 'x86_64':
|
||||
return 'x64';
|
||||
case 'arm64':
|
||||
case 'aarch64':
|
||||
return 'arm64';
|
||||
case 'ia32':
|
||||
case 'x86':
|
||||
// TODO: Add support for 32-bit x86 (ia32)
|
||||
// Currently blocked because the seccomp filter does not block the socketcall() syscall,
|
||||
// which is used on 32-bit x86 for all socket operations (socket, socketpair, bind, connect, etc.).
|
||||
// On 32-bit x86, the direct socket() syscall doesn't exist - instead, all socket operations
|
||||
// are multiplexed through socketcall(SYS_SOCKET, ...), socketcall(SYS_SOCKETPAIR, ...), etc.
|
||||
//
|
||||
// To properly support 32-bit x86, we need to:
|
||||
// 1. Build a separate i386 BPF filter (BPF bytecode is architecture-specific)
|
||||
// 2. Modify vendor/seccomp-src/seccomp-unix-block.c to conditionally add rules that block:
|
||||
// - socketcall(SYS_SOCKET, [AF_UNIX, ...])
|
||||
// - socketcall(SYS_SOCKETPAIR, [AF_UNIX, ...])
|
||||
// 3. This requires complex BPF logic to inspect socketcall's sub-function argument
|
||||
//
|
||||
// Until then, 32-bit x86 is not supported to avoid a security bypass.
|
||||
logForDebugging(`[SeccompFilter] 32-bit x86 (ia32) is not currently supported due to missing socketcall() syscall blocking. ` +
|
||||
`The current seccomp filter only blocks socket(AF_UNIX, ...), but on 32-bit x86, socketcall() can be used to bypass this.`, { level: 'error' });
|
||||
return null;
|
||||
default:
|
||||
logForDebugging(`[SeccompFilter] Unsupported architecture: ${arch}. Only x64 and arm64 are supported.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get local paths to check for seccomp files (bundled or package installs).
|
||||
*/
|
||||
function getLocalSeccompPaths(filename) {
|
||||
const arch = getVendorArchitecture();
|
||||
if (!arch)
|
||||
return [];
|
||||
const baseDir = dirname(fileURLToPath(import.meta.url));
|
||||
const relativePath = join('vendor', 'seccomp', arch, filename);
|
||||
return [
|
||||
join(baseDir, relativePath), // bundled: same directory as bundle (e.g., when bundled into claude-cli)
|
||||
join(baseDir, '..', '..', relativePath), // package root: vendor/seccomp/...
|
||||
join(baseDir, '..', relativePath), // dist: dist/vendor/seccomp/...
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Get the path to a pre-generated BPF filter file from the vendor directory
|
||||
* Returns the path if it exists, null otherwise
|
||||
*
|
||||
* Pre-generated BPF files are organized by architecture:
|
||||
* - vendor/seccomp/{x64,arm64}/unix-block.bpf
|
||||
*
|
||||
* Tries multiple paths for resilience:
|
||||
* 0. Explicit path provided via parameter (checked first if provided)
|
||||
* 1. vendor/seccomp/{arch}/unix-block.bpf (bundled - when bundled into consuming packages)
|
||||
* 2. ../../vendor/seccomp/{arch}/unix-block.bpf (package root - standard npm installs)
|
||||
* 3. ../vendor/seccomp/{arch}/unix-block.bpf (dist/vendor - for bundlers)
|
||||
* 4. Global npm install (if seccompBinaryPath not provided) - for native builds
|
||||
*
|
||||
* @param seccompBinaryPath - Optional explicit path to the BPF filter file. If provided and
|
||||
* exists, it will be used. If not provided, falls back to searching local paths and then
|
||||
* global npm install (for native builds where vendor directory isn't bundled).
|
||||
*/
|
||||
export function getPreGeneratedBpfPath(seccompBinaryPath) {
|
||||
const cacheKey = seccompBinaryPath ?? '';
|
||||
if (bpfPathCache.has(cacheKey)) {
|
||||
return bpfPathCache.get(cacheKey);
|
||||
}
|
||||
const result = findBpfPath(seccompBinaryPath);
|
||||
bpfPathCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
// NOTE: This is a slow operation (synchronous fs lookups + execSync). Ensure calls
|
||||
// are memoized at the top level rather than invoked repeatedly.
|
||||
function findBpfPath(seccompBinaryPath) {
|
||||
// Check explicit path first (highest priority)
|
||||
if (seccompBinaryPath) {
|
||||
if (fs.existsSync(seccompBinaryPath)) {
|
||||
logForDebugging(`[SeccompFilter] Using BPF filter from explicit path: ${seccompBinaryPath}`);
|
||||
return seccompBinaryPath;
|
||||
}
|
||||
logForDebugging(`[SeccompFilter] Explicit path provided but file not found: ${seccompBinaryPath}`);
|
||||
}
|
||||
const arch = getVendorArchitecture();
|
||||
if (!arch) {
|
||||
logForDebugging(`[SeccompFilter] Cannot find pre-generated BPF filter: unsupported architecture ${process.arch}`);
|
||||
return null;
|
||||
}
|
||||
logForDebugging(`[SeccompFilter] Detected architecture: ${arch}`);
|
||||
// Check local paths first (bundled or package install)
|
||||
for (const bpfPath of getLocalSeccompPaths('unix-block.bpf')) {
|
||||
if (fs.existsSync(bpfPath)) {
|
||||
logForDebugging(`[SeccompFilter] Found pre-generated BPF filter: ${bpfPath} (${arch})`);
|
||||
return bpfPath;
|
||||
}
|
||||
}
|
||||
// Fallback: check global npm install (for native builds without bundled vendor)
|
||||
for (const globalBase of getGlobalNpmPaths()) {
|
||||
const bpfPath = join(globalBase, 'vendor', 'seccomp', arch, 'unix-block.bpf');
|
||||
if (fs.existsSync(bpfPath)) {
|
||||
logForDebugging(`[SeccompFilter] Found pre-generated BPF filter in global install: ${bpfPath} (${arch})`);
|
||||
return bpfPath;
|
||||
}
|
||||
}
|
||||
logForDebugging(`[SeccompFilter] Pre-generated BPF filter not found in any expected location (${arch})`);
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Get the path to the apply-seccomp binary from the vendor directory
|
||||
* Returns the path if it exists, null otherwise
|
||||
*
|
||||
* Pre-built apply-seccomp binaries are organized by architecture:
|
||||
* - vendor/seccomp/{x64,arm64}/apply-seccomp
|
||||
*
|
||||
* Tries multiple paths for resilience:
|
||||
* 0. Explicit path provided via parameter (checked first if provided)
|
||||
* 1. vendor/seccomp/{arch}/apply-seccomp (bundled - when bundled into consuming packages)
|
||||
* 2. ../../vendor/seccomp/{arch}/apply-seccomp (package root - standard npm installs)
|
||||
* 3. ../vendor/seccomp/{arch}/apply-seccomp (dist/vendor - for bundlers)
|
||||
* 4. Global npm install (if seccompBinaryPath not provided) - for native builds
|
||||
*
|
||||
* @param seccompBinaryPath - Optional explicit path to the apply-seccomp binary. If provided
|
||||
* and exists, it will be used. If not provided, falls back to searching local paths and
|
||||
* then global npm install (for native builds where vendor directory isn't bundled).
|
||||
*/
|
||||
export function getApplySeccompBinaryPath(seccompBinaryPath) {
|
||||
const cacheKey = seccompBinaryPath ?? '';
|
||||
if (applySeccompPathCache.has(cacheKey)) {
|
||||
return applySeccompPathCache.get(cacheKey);
|
||||
}
|
||||
const result = findApplySeccompPath(seccompBinaryPath);
|
||||
applySeccompPathCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
function findApplySeccompPath(seccompBinaryPath) {
|
||||
// Check explicit path first (highest priority)
|
||||
if (seccompBinaryPath) {
|
||||
if (fs.existsSync(seccompBinaryPath)) {
|
||||
logForDebugging(`[SeccompFilter] Using apply-seccomp binary from explicit path: ${seccompBinaryPath}`);
|
||||
return seccompBinaryPath;
|
||||
}
|
||||
logForDebugging(`[SeccompFilter] Explicit path provided but file not found: ${seccompBinaryPath}`);
|
||||
}
|
||||
const arch = getVendorArchitecture();
|
||||
if (!arch) {
|
||||
logForDebugging(`[SeccompFilter] Cannot find apply-seccomp binary: unsupported architecture ${process.arch}`);
|
||||
return null;
|
||||
}
|
||||
logForDebugging(`[SeccompFilter] Looking for apply-seccomp binary for architecture: ${arch}`);
|
||||
// Check local paths first (bundled or package install)
|
||||
for (const binaryPath of getLocalSeccompPaths('apply-seccomp')) {
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
logForDebugging(`[SeccompFilter] Found apply-seccomp binary: ${binaryPath} (${arch})`);
|
||||
return binaryPath;
|
||||
}
|
||||
}
|
||||
// Fallback: check global npm install (for native builds without bundled vendor)
|
||||
for (const globalBase of getGlobalNpmPaths()) {
|
||||
const binaryPath = join(globalBase, 'vendor', 'seccomp', arch, 'apply-seccomp');
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
logForDebugging(`[SeccompFilter] Found apply-seccomp binary in global install: ${binaryPath} (${arch})`);
|
||||
return binaryPath;
|
||||
}
|
||||
}
|
||||
logForDebugging(`[SeccompFilter] apply-seccomp binary not found in any expected location (${arch})`);
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Get the path to a pre-generated seccomp BPF filter that blocks Unix domain socket creation
|
||||
* Returns the path to the BPF filter file, or null if not available
|
||||
*
|
||||
* The filter blocks socket(AF_UNIX, ...) syscalls while allowing all other syscalls.
|
||||
* This prevents creation of new Unix domain socket file descriptors.
|
||||
*
|
||||
* Security scope:
|
||||
* - Blocks: socket(AF_UNIX, ...) syscall (creating new Unix socket FDs)
|
||||
* - Does NOT block: Operations on inherited Unix socket FDs (bind, connect, sendto, etc.)
|
||||
* - Does NOT block: Unix socket FDs passed via SCM_RIGHTS
|
||||
* - For most sandboxing scenarios, blocking socket creation is sufficient
|
||||
*
|
||||
* Note: This blocks ALL Unix socket creation, regardless of path. The allowUnixSockets
|
||||
* configuration is not supported on Linux due to seccomp-bpf limitations (it cannot
|
||||
* read user-space memory to inspect socket paths).
|
||||
*
|
||||
* Requirements:
|
||||
* - Pre-generated BPF filters included for x64 and ARM64 only
|
||||
* - Other architectures are not supported
|
||||
*
|
||||
* @param seccompBinaryPath - Optional explicit path to the BPF filter file
|
||||
* @returns Path to the pre-generated BPF filter file, or null if not available
|
||||
*/
|
||||
export function generateSeccompFilter(seccompBinaryPath) {
|
||||
const preGeneratedBpf = getPreGeneratedBpfPath(seccompBinaryPath);
|
||||
if (preGeneratedBpf) {
|
||||
logForDebugging('[SeccompFilter] Using pre-generated BPF filter');
|
||||
return preGeneratedBpf;
|
||||
}
|
||||
logForDebugging('[SeccompFilter] Pre-generated BPF filter not available for this architecture. ' +
|
||||
'Only x64 and arm64 are supported.', { level: 'error' });
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Clean up a seccomp filter file
|
||||
* Since we only use pre-generated BPF files from vendor/, this is a no-op.
|
||||
* Pre-generated files are never deleted.
|
||||
* Kept for backward compatibility with existing code that calls it.
|
||||
*/
|
||||
export function cleanupSeccompFilter(_filterPath) {
|
||||
// No-op: pre-generated BPF files are never cleaned up
|
||||
}
|
||||
//# sourceMappingURL=generate-seccomp-filter.js.map
|
||||
217
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.js
vendored
Normal file
217
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.js
vendored
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Agent, createServer } from 'node:http';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { connect } from 'node:net';
|
||||
import { URL } from 'node:url';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
export function createHttpProxyServer(options) {
|
||||
const server = createServer();
|
||||
// Handle CONNECT requests for HTTPS traffic
|
||||
server.on('connect', async (req, socket) => {
|
||||
// Attach error handler immediately to prevent unhandled errors
|
||||
socket.on('error', err => {
|
||||
logForDebugging(`Client socket error: ${err.message}`, { level: 'error' });
|
||||
});
|
||||
try {
|
||||
const [hostname, portStr] = req.url.split(':');
|
||||
const port = portStr === undefined ? undefined : parseInt(portStr, 10);
|
||||
if (!hostname || !port) {
|
||||
logForDebugging(`Invalid CONNECT request: ${req.url}`, {
|
||||
level: 'error',
|
||||
});
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
const allowed = await options.filter(port, hostname, socket);
|
||||
if (!allowed) {
|
||||
logForDebugging(`Connection blocked to ${hostname}:${port}`, {
|
||||
level: 'error',
|
||||
});
|
||||
socket.end('HTTP/1.1 403 Forbidden\r\n' +
|
||||
'Content-Type: text/plain\r\n' +
|
||||
'X-Proxy-Error: blocked-by-allowlist\r\n' +
|
||||
'\r\n' +
|
||||
'Connection blocked by network allowlist');
|
||||
return;
|
||||
}
|
||||
// Check if this host should be routed through a MITM proxy
|
||||
const mitmSocketPath = options.getMitmSocketPath?.(hostname);
|
||||
if (mitmSocketPath) {
|
||||
// Route through MITM proxy via Unix socket
|
||||
logForDebugging(`Routing CONNECT ${hostname}:${port} through MITM proxy at ${mitmSocketPath}`);
|
||||
const mitmSocket = connect({ path: mitmSocketPath }, () => {
|
||||
// Send CONNECT request to the MITM proxy
|
||||
mitmSocket.write(`CONNECT ${hostname}:${port} HTTP/1.1\r\n` +
|
||||
`Host: ${hostname}:${port}\r\n` +
|
||||
'\r\n');
|
||||
});
|
||||
// Buffer to accumulate the MITM proxy's response
|
||||
let responseBuffer = '';
|
||||
const onMitmData = (chunk) => {
|
||||
responseBuffer += chunk.toString();
|
||||
// Check if we've received the full HTTP response headers
|
||||
const headerEndIndex = responseBuffer.indexOf('\r\n\r\n');
|
||||
if (headerEndIndex !== -1) {
|
||||
// Remove data listener, we're done parsing the response
|
||||
mitmSocket.removeListener('data', onMitmData);
|
||||
// Check if MITM proxy accepted the connection
|
||||
const statusLine = responseBuffer.substring(0, responseBuffer.indexOf('\r\n'));
|
||||
if (statusLine.includes(' 200 ')) {
|
||||
// Connection established, now pipe data between client and MITM
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
// If there's any data after the headers, write it to the client
|
||||
const remainingData = responseBuffer.substring(headerEndIndex + 4);
|
||||
if (remainingData.length > 0) {
|
||||
socket.write(remainingData);
|
||||
}
|
||||
mitmSocket.pipe(socket);
|
||||
socket.pipe(mitmSocket);
|
||||
}
|
||||
else {
|
||||
logForDebugging(`MITM proxy rejected CONNECT: ${statusLine}`, {
|
||||
level: 'error',
|
||||
});
|
||||
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
mitmSocket.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
mitmSocket.on('data', onMitmData);
|
||||
mitmSocket.on('error', err => {
|
||||
logForDebugging(`MITM proxy connection failed: ${err.message}`, {
|
||||
level: 'error',
|
||||
});
|
||||
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
});
|
||||
socket.on('error', err => {
|
||||
logForDebugging(`Client socket error: ${err.message}`, {
|
||||
level: 'error',
|
||||
});
|
||||
mitmSocket.destroy();
|
||||
});
|
||||
socket.on('end', () => mitmSocket.end());
|
||||
mitmSocket.on('end', () => socket.end());
|
||||
}
|
||||
else {
|
||||
// Direct connection (original behavior)
|
||||
const serverSocket = connect(port, hostname, () => {
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
serverSocket.pipe(socket);
|
||||
socket.pipe(serverSocket);
|
||||
});
|
||||
serverSocket.on('error', err => {
|
||||
logForDebugging(`CONNECT tunnel failed: ${err.message}`, {
|
||||
level: 'error',
|
||||
});
|
||||
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
});
|
||||
socket.on('error', err => {
|
||||
logForDebugging(`Client socket error: ${err.message}`, {
|
||||
level: 'error',
|
||||
});
|
||||
serverSocket.destroy();
|
||||
});
|
||||
socket.on('end', () => serverSocket.end());
|
||||
serverSocket.on('end', () => socket.end());
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logForDebugging(`Error handling CONNECT: ${err}`, { level: 'error' });
|
||||
socket.end('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||
}
|
||||
});
|
||||
// Handle regular HTTP requests
|
||||
server.on('request', async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const hostname = url.hostname;
|
||||
const port = url.port
|
||||
? parseInt(url.port, 10)
|
||||
: url.protocol === 'https:'
|
||||
? 443
|
||||
: 80;
|
||||
const allowed = await options.filter(port, hostname, req.socket);
|
||||
if (!allowed) {
|
||||
logForDebugging(`HTTP request blocked to ${hostname}:${port}`, {
|
||||
level: 'error',
|
||||
});
|
||||
res.writeHead(403, {
|
||||
'Content-Type': 'text/plain',
|
||||
'X-Proxy-Error': 'blocked-by-allowlist',
|
||||
});
|
||||
res.end('Connection blocked by network allowlist');
|
||||
return;
|
||||
}
|
||||
// Check if this host should be routed through a MITM proxy
|
||||
const mitmSocketPath = options.getMitmSocketPath?.(hostname);
|
||||
if (mitmSocketPath) {
|
||||
// Route through MITM proxy via Unix socket
|
||||
// Use an agent that connects via the Unix socket
|
||||
logForDebugging(`Routing HTTP ${req.method} ${hostname}:${port} through MITM proxy at ${mitmSocketPath}`);
|
||||
const mitmAgent = new Agent({
|
||||
// @ts-expect-error - socketPath is valid but not in types
|
||||
socketPath: mitmSocketPath,
|
||||
});
|
||||
// Send request to MITM proxy with full URL (proxy-style request)
|
||||
const proxyReq = httpRequest({
|
||||
agent: mitmAgent,
|
||||
// For proxy requests, path should be the full URL
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: url.host,
|
||||
},
|
||||
}, proxyRes => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
proxyReq.on('error', err => {
|
||||
logForDebugging(`MITM proxy request failed: ${err.message}`, {
|
||||
level: 'error',
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
||||
res.end('Bad Gateway');
|
||||
}
|
||||
});
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
else {
|
||||
// Direct request (original behavior)
|
||||
// Choose http or https module
|
||||
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const proxyReq = requestFn({
|
||||
hostname,
|
||||
port,
|
||||
path: url.pathname + url.search,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: url.host,
|
||||
},
|
||||
}, proxyRes => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
proxyReq.on('error', err => {
|
||||
logForDebugging(`Proxy request failed: ${err.message}`, {
|
||||
level: 'error',
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
||||
res.end('Bad Gateway');
|
||||
}
|
||||
});
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logForDebugging(`Error handling HTTP request: ${err}`, { level: 'error' });
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
});
|
||||
return server;
|
||||
}
|
||||
//# sourceMappingURL=http-proxy.js.map
|
||||
875
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js
vendored
Normal file
875
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js
vendored
Normal file
@@ -0,0 +1,875 @@
|
||||
import shellquote from 'shell-quote';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
import { whichSync } from '../utils/which.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import * as fs from 'fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path, { join } from 'node:path';
|
||||
import { ripGrep } from '../utils/ripgrep.js';
|
||||
import { generateProxyEnvVars, normalizePathForSandbox, normalizeCaseForComparison, isSymlinkOutsideBoundary, DANGEROUS_FILES, getDangerousDirectories, } from './sandbox-utils.js';
|
||||
import { generateSeccompFilter, cleanupSeccompFilter, getPreGeneratedBpfPath, getApplySeccompBinaryPath, } from './generate-seccomp-filter.js';
|
||||
/** Default max depth for searching dangerous files */
|
||||
const DEFAULT_MANDATORY_DENY_SEARCH_DEPTH = 3;
|
||||
/**
|
||||
* Find if any component of the path is a symlink within the allowed write paths.
|
||||
* Returns the symlink path if found, or null if no symlinks.
|
||||
*
|
||||
* This is used to detect and block symlink replacement attacks where an attacker
|
||||
* could delete a symlink and create a real directory with malicious content.
|
||||
*/
|
||||
function findSymlinkInPath(targetPath, allowedWritePaths) {
|
||||
const parts = targetPath.split(path.sep);
|
||||
let currentPath = '';
|
||||
for (const part of parts) {
|
||||
if (!part)
|
||||
continue; // Skip empty parts (leading /)
|
||||
const nextPath = currentPath + path.sep + part;
|
||||
try {
|
||||
const stats = fs.lstatSync(nextPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
// Check if this symlink is within an allowed write path
|
||||
const isWithinAllowedPath = allowedWritePaths.some(allowedPath => nextPath.startsWith(allowedPath + '/') || nextPath === allowedPath);
|
||||
if (isWithinAllowedPath) {
|
||||
return nextPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Path doesn't exist - no symlink issue here
|
||||
break;
|
||||
}
|
||||
currentPath = nextPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Check if any existing component in the path is a file (not a directory).
|
||||
* If so, the target path can never be created because you can't mkdir under a file.
|
||||
*
|
||||
* This handles the git worktree case: .git is a file, so .git/hooks can never
|
||||
* exist and there's nothing to deny.
|
||||
*/
|
||||
function hasFileAncestor(targetPath) {
|
||||
const parts = targetPath.split(path.sep);
|
||||
let currentPath = '';
|
||||
for (const part of parts) {
|
||||
if (!part)
|
||||
continue; // Skip empty parts (leading /)
|
||||
const nextPath = currentPath + path.sep + part;
|
||||
try {
|
||||
const stat = fs.statSync(nextPath);
|
||||
if (stat.isFile() || stat.isSymbolicLink()) {
|
||||
// This component exists as a file — nothing below it can be created
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Path doesn't exist — stop checking
|
||||
break;
|
||||
}
|
||||
currentPath = nextPath;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Find the first non-existent path component.
|
||||
* E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists,
|
||||
* returns "/existing/parent/nonexistent"
|
||||
*
|
||||
* This is used to block creation of non-existent deny paths by mounting /dev/null
|
||||
* at the first missing component, preventing mkdir from creating the parent directories.
|
||||
*/
|
||||
function findFirstNonExistentComponent(targetPath) {
|
||||
const parts = targetPath.split(path.sep);
|
||||
let currentPath = '';
|
||||
for (const part of parts) {
|
||||
if (!part)
|
||||
continue; // Skip empty parts (leading /)
|
||||
const nextPath = currentPath + path.sep + part;
|
||||
if (!fs.existsSync(nextPath)) {
|
||||
return nextPath;
|
||||
}
|
||||
currentPath = nextPath;
|
||||
}
|
||||
return targetPath; // Shouldn't reach here if called correctly
|
||||
}
|
||||
/**
|
||||
* Get mandatory deny paths using ripgrep (Linux only).
|
||||
* Uses a SINGLE ripgrep call with multiple glob patterns for efficiency.
|
||||
* With --max-depth limiting, this is fast enough to run on each command without memoization.
|
||||
*/
|
||||
async function linuxGetMandatoryDenyPaths(ripgrepConfig = { command: 'rg' }, maxDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal) {
|
||||
const cwd = process.cwd();
|
||||
// Use provided signal or create a fallback controller
|
||||
const fallbackController = new AbortController();
|
||||
const signal = abortSignal ?? fallbackController.signal;
|
||||
const dangerousDirectories = getDangerousDirectories();
|
||||
// Note: Settings files are added at the callsite in sandbox-manager.ts
|
||||
const denyPaths = [
|
||||
// Dangerous files in CWD
|
||||
...DANGEROUS_FILES.map(f => path.resolve(cwd, f)),
|
||||
// Dangerous directories in CWD
|
||||
...dangerousDirectories.map(d => path.resolve(cwd, d)),
|
||||
];
|
||||
// Git hooks and config are only denied when .git exists as a directory.
|
||||
// In git worktrees, .git is a file (e.g., "gitdir: /path/..."), so
|
||||
// .git/hooks can never exist — denying it would cause bwrap to fail.
|
||||
// When .git doesn't exist at all, mounting at .git would block its
|
||||
// creation and break git init.
|
||||
const dotGitPath = path.resolve(cwd, '.git');
|
||||
let dotGitIsDirectory = false;
|
||||
try {
|
||||
dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory();
|
||||
}
|
||||
catch {
|
||||
// .git doesn't exist
|
||||
}
|
||||
if (dotGitIsDirectory) {
|
||||
// Git hooks always blocked for security
|
||||
denyPaths.push(path.resolve(cwd, '.git/hooks'));
|
||||
// Git config conditionally blocked based on allowGitConfig setting
|
||||
if (!allowGitConfig) {
|
||||
denyPaths.push(path.resolve(cwd, '.git/config'));
|
||||
}
|
||||
}
|
||||
// Build iglob args for all patterns in one ripgrep call
|
||||
const iglobArgs = [];
|
||||
for (const fileName of DANGEROUS_FILES) {
|
||||
iglobArgs.push('--iglob', fileName);
|
||||
}
|
||||
for (const dirName of dangerousDirectories) {
|
||||
iglobArgs.push('--iglob', `**/${dirName}/**`);
|
||||
}
|
||||
// Git hooks always blocked in nested repos
|
||||
iglobArgs.push('--iglob', '**/.git/hooks/**');
|
||||
// Git config conditionally blocked in nested repos
|
||||
if (!allowGitConfig) {
|
||||
iglobArgs.push('--iglob', '**/.git/config');
|
||||
}
|
||||
// Single ripgrep call to find all dangerous paths in subdirectories
|
||||
// Limit depth for performance - deeply nested dangerous files are rare
|
||||
// and the security benefit doesn't justify the traversal cost
|
||||
let matches = [];
|
||||
try {
|
||||
matches = await ripGrep([
|
||||
'--files',
|
||||
'--hidden',
|
||||
'--max-depth',
|
||||
String(maxDepth),
|
||||
...iglobArgs,
|
||||
'-g',
|
||||
'!**/node_modules/**',
|
||||
], cwd, signal, ripgrepConfig);
|
||||
}
|
||||
catch (error) {
|
||||
logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`);
|
||||
}
|
||||
// Process matches
|
||||
for (const match of matches) {
|
||||
const absolutePath = path.resolve(cwd, match);
|
||||
// File inside a dangerous directory -> add the directory path
|
||||
let foundDir = false;
|
||||
for (const dirName of [...dangerousDirectories, '.git']) {
|
||||
const normalizedDirName = normalizeCaseForComparison(dirName);
|
||||
const segments = absolutePath.split(path.sep);
|
||||
const dirIndex = segments.findIndex(s => normalizeCaseForComparison(s) === normalizedDirName);
|
||||
if (dirIndex !== -1) {
|
||||
// For .git, we want hooks/ or config, not the whole .git dir
|
||||
if (dirName === '.git') {
|
||||
const gitDir = segments.slice(0, dirIndex + 1).join(path.sep);
|
||||
if (match.includes('.git/hooks')) {
|
||||
denyPaths.push(path.join(gitDir, 'hooks'));
|
||||
}
|
||||
else if (match.includes('.git/config')) {
|
||||
denyPaths.push(path.join(gitDir, 'config'));
|
||||
}
|
||||
}
|
||||
else {
|
||||
denyPaths.push(segments.slice(0, dirIndex + 1).join(path.sep));
|
||||
}
|
||||
foundDir = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Dangerous file match
|
||||
if (!foundDir) {
|
||||
denyPaths.push(absolutePath);
|
||||
}
|
||||
}
|
||||
return [...new Set(denyPaths)];
|
||||
}
|
||||
// Track generated seccomp filters for cleanup on process exit
|
||||
const generatedSeccompFilters = new Set();
|
||||
// Track mount points created by bwrap for non-existent deny paths.
|
||||
// When bwrap does --ro-bind /dev/null /nonexistent/path, it creates an empty
|
||||
// file on the host as a mount point. These persist after bwrap exits and must
|
||||
// be cleaned up explicitly.
|
||||
const bwrapMountPoints = new Set();
|
||||
let exitHandlerRegistered = false;
|
||||
/**
|
||||
* Register cleanup handler for generated seccomp filters and bwrap mount points
|
||||
*/
|
||||
function registerExitCleanupHandler() {
|
||||
if (exitHandlerRegistered) {
|
||||
return;
|
||||
}
|
||||
process.on('exit', () => {
|
||||
for (const filterPath of generatedSeccompFilters) {
|
||||
try {
|
||||
cleanupSeccompFilter(filterPath);
|
||||
}
|
||||
catch {
|
||||
// Ignore cleanup errors during exit
|
||||
}
|
||||
}
|
||||
cleanupBwrapMountPoints();
|
||||
});
|
||||
exitHandlerRegistered = true;
|
||||
}
|
||||
/**
|
||||
* Clean up mount point files created by bwrap for non-existent deny paths.
|
||||
*
|
||||
* When protecting non-existent deny paths, bwrap creates empty files on the
|
||||
* host filesystem as mount points for --ro-bind. These files persist after
|
||||
* bwrap exits. This function removes them.
|
||||
*
|
||||
* This should be called after each sandboxed command completes to prevent
|
||||
* ghost dotfiles (e.g. .bashrc, .gitconfig) from appearing in the working
|
||||
* directory. It is also called automatically on process exit as a safety net.
|
||||
*
|
||||
* Safe to call at any time — it only removes files that were tracked during
|
||||
* generateFilesystemArgs() and skips any that no longer exist.
|
||||
*/
|
||||
export function cleanupBwrapMountPoints() {
|
||||
for (const mountPoint of bwrapMountPoints) {
|
||||
try {
|
||||
// Only remove if it's still the empty file/directory bwrap created.
|
||||
// If something else has written real content, leave it alone.
|
||||
const stat = fs.statSync(mountPoint);
|
||||
if (stat.isFile() && stat.size === 0) {
|
||||
fs.unlinkSync(mountPoint);
|
||||
logForDebugging(`[Sandbox Linux] Cleaned up bwrap mount point (file): ${mountPoint}`);
|
||||
}
|
||||
else if (stat.isDirectory()) {
|
||||
// Empty directory mount points are created for intermediate
|
||||
// components (Fix 2). Only remove if still empty.
|
||||
const entries = fs.readdirSync(mountPoint);
|
||||
if (entries.length === 0) {
|
||||
fs.rmdirSync(mountPoint);
|
||||
logForDebugging(`[Sandbox Linux] Cleaned up bwrap mount point (dir): ${mountPoint}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore cleanup errors — the file may have already been removed
|
||||
}
|
||||
}
|
||||
bwrapMountPoints.clear();
|
||||
}
|
||||
/**
|
||||
* Get detailed status of Linux sandbox dependencies
|
||||
*/
|
||||
export function getLinuxDependencyStatus(seccompConfig) {
|
||||
return {
|
||||
hasBwrap: whichSync('bwrap') !== null,
|
||||
hasSocat: whichSync('socat') !== null,
|
||||
hasSeccompBpf: getPreGeneratedBpfPath(seccompConfig?.bpfPath) !== null,
|
||||
hasSeccompApply: getApplySeccompBinaryPath(seccompConfig?.applyPath) !== null,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Check sandbox dependencies and return structured result
|
||||
*/
|
||||
export function checkLinuxDependencies(seccompConfig) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
if (whichSync('bwrap') === null)
|
||||
errors.push('bubblewrap (bwrap) not installed');
|
||||
if (whichSync('socat') === null)
|
||||
errors.push('socat not installed');
|
||||
const hasBpf = getPreGeneratedBpfPath(seccompConfig?.bpfPath) !== null;
|
||||
const hasApply = getApplySeccompBinaryPath(seccompConfig?.applyPath) !== null;
|
||||
if (!hasBpf || !hasApply) {
|
||||
warnings.push('seccomp not available - unix socket access not restricted');
|
||||
}
|
||||
return { warnings, errors };
|
||||
}
|
||||
/**
|
||||
* Initialize the Linux network bridge for sandbox networking
|
||||
*
|
||||
* ARCHITECTURE NOTE:
|
||||
* Linux network sandboxing uses bwrap --unshare-net which creates a completely isolated
|
||||
* network namespace with NO network access. To enable network access, we:
|
||||
*
|
||||
* 1. Host side: Run socat bridges that listen on Unix sockets and forward to host proxy servers
|
||||
* - HTTP bridge: Unix socket -> host HTTP proxy (for HTTP/HTTPS traffic)
|
||||
* - SOCKS bridge: Unix socket -> host SOCKS5 proxy (for SSH/git traffic)
|
||||
*
|
||||
* 2. Sandbox side: Bind the Unix sockets into the isolated namespace and run socat listeners
|
||||
* - HTTP listener on port 3128 -> HTTP Unix socket -> host HTTP proxy
|
||||
* - SOCKS listener on port 1080 -> SOCKS Unix socket -> host SOCKS5 proxy
|
||||
*
|
||||
* 3. Configure environment:
|
||||
* - HTTP_PROXY=http://localhost:3128 for HTTP/HTTPS tools
|
||||
* - GIT_SSH_COMMAND with socat for SSH through SOCKS5
|
||||
*
|
||||
* LIMITATION: Unlike macOS sandbox which can enforce domain-based allowlists at the kernel level,
|
||||
* Linux's --unshare-net provides only all-or-nothing network isolation. Domain filtering happens
|
||||
* at the host proxy level, not the sandbox boundary. This means network restrictions on Linux
|
||||
* depend on the proxy's filtering capabilities.
|
||||
*
|
||||
* DEPENDENCIES: Requires bwrap (bubblewrap) and socat
|
||||
*/
|
||||
export async function initializeLinuxNetworkBridge(httpProxyPort, socksProxyPort) {
|
||||
const socketId = randomBytes(8).toString('hex');
|
||||
const httpSocketPath = join(tmpdir(), `claude-http-${socketId}.sock`);
|
||||
const socksSocketPath = join(tmpdir(), `claude-socks-${socketId}.sock`);
|
||||
// Start HTTP bridge
|
||||
const httpSocatArgs = [
|
||||
`UNIX-LISTEN:${httpSocketPath},fork,reuseaddr`,
|
||||
`TCP:localhost:${httpProxyPort},keepalive,keepidle=10,keepintvl=5,keepcnt=3`,
|
||||
];
|
||||
logForDebugging(`Starting HTTP bridge: socat ${httpSocatArgs.join(' ')}`);
|
||||
const httpBridgeProcess = spawn('socat', httpSocatArgs, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (!httpBridgeProcess.pid) {
|
||||
throw new Error('Failed to start HTTP bridge process');
|
||||
}
|
||||
// Add error and exit handlers to monitor bridge health
|
||||
httpBridgeProcess.on('error', err => {
|
||||
logForDebugging(`HTTP bridge process error: ${err}`, { level: 'error' });
|
||||
});
|
||||
httpBridgeProcess.on('exit', (code, signal) => {
|
||||
logForDebugging(`HTTP bridge process exited with code ${code}, signal ${signal}`, { level: code === 0 ? 'info' : 'error' });
|
||||
});
|
||||
// Start SOCKS bridge
|
||||
const socksSocatArgs = [
|
||||
`UNIX-LISTEN:${socksSocketPath},fork,reuseaddr`,
|
||||
`TCP:localhost:${socksProxyPort},keepalive,keepidle=10,keepintvl=5,keepcnt=3`,
|
||||
];
|
||||
logForDebugging(`Starting SOCKS bridge: socat ${socksSocatArgs.join(' ')}`);
|
||||
const socksBridgeProcess = spawn('socat', socksSocatArgs, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (!socksBridgeProcess.pid) {
|
||||
// Clean up HTTP bridge
|
||||
if (httpBridgeProcess.pid) {
|
||||
try {
|
||||
process.kill(httpBridgeProcess.pid, 'SIGTERM');
|
||||
}
|
||||
catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to start SOCKS bridge process');
|
||||
}
|
||||
// Add error and exit handlers to monitor bridge health
|
||||
socksBridgeProcess.on('error', err => {
|
||||
logForDebugging(`SOCKS bridge process error: ${err}`, { level: 'error' });
|
||||
});
|
||||
socksBridgeProcess.on('exit', (code, signal) => {
|
||||
logForDebugging(`SOCKS bridge process exited with code ${code}, signal ${signal}`, { level: code === 0 ? 'info' : 'error' });
|
||||
});
|
||||
// Wait for both sockets to be ready
|
||||
const maxAttempts = 5;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (!httpBridgeProcess.pid ||
|
||||
httpBridgeProcess.killed ||
|
||||
!socksBridgeProcess.pid ||
|
||||
socksBridgeProcess.killed) {
|
||||
throw new Error('Linux bridge process died unexpectedly');
|
||||
}
|
||||
try {
|
||||
// fs already imported
|
||||
if (fs.existsSync(httpSocketPath) && fs.existsSync(socksSocketPath)) {
|
||||
logForDebugging(`Linux bridges ready after ${i + 1} attempts`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logForDebugging(`Error checking sockets (attempt ${i + 1}): ${err}`, {
|
||||
level: 'error',
|
||||
});
|
||||
}
|
||||
if (i === maxAttempts - 1) {
|
||||
// Clean up both processes
|
||||
if (httpBridgeProcess.pid) {
|
||||
try {
|
||||
process.kill(httpBridgeProcess.pid, 'SIGTERM');
|
||||
}
|
||||
catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
if (socksBridgeProcess.pid) {
|
||||
try {
|
||||
process.kill(socksBridgeProcess.pid, 'SIGTERM');
|
||||
}
|
||||
catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to create bridge sockets after ${maxAttempts} attempts`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, i * 100));
|
||||
}
|
||||
return {
|
||||
httpSocketPath,
|
||||
socksSocketPath,
|
||||
httpBridgeProcess,
|
||||
socksBridgeProcess,
|
||||
httpProxyPort,
|
||||
socksProxyPort,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Build the command that runs inside the sandbox.
|
||||
* Sets up HTTP proxy on port 3128 and SOCKS proxy on port 1080
|
||||
*/
|
||||
function buildSandboxCommand(httpSocketPath, socksSocketPath, userCommand, seccompFilterPath, shell, applySeccompPath) {
|
||||
// Default to bash for backward compatibility
|
||||
const shellPath = shell || 'bash';
|
||||
const socatCommands = [
|
||||
`socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:${httpSocketPath} >/dev/null 2>&1 &`,
|
||||
`socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:${socksSocketPath} >/dev/null 2>&1 &`,
|
||||
'trap "kill %1 %2 2>/dev/null; exit" EXIT',
|
||||
];
|
||||
// If seccomp filter is provided, use apply-seccomp to apply it
|
||||
if (seccompFilterPath) {
|
||||
// apply-seccomp approach:
|
||||
// 1. Outer bwrap/bash: starts socat processes (can use Unix sockets)
|
||||
// 2. apply-seccomp: applies seccomp filter and execs user command
|
||||
// 3. User command runs with seccomp active (Unix sockets blocked)
|
||||
//
|
||||
// apply-seccomp is a simple C program that:
|
||||
// - Sets PR_SET_NO_NEW_PRIVS
|
||||
// - Applies the seccomp BPF filter via prctl(PR_SET_SECCOMP)
|
||||
// - Execs the user command
|
||||
//
|
||||
// This is simpler and more portable than nested bwrap, with no FD redirects needed.
|
||||
const applySeccompBinary = getApplySeccompBinaryPath(applySeccompPath);
|
||||
if (!applySeccompBinary) {
|
||||
throw new Error('apply-seccomp binary not found. This should have been caught earlier. ' +
|
||||
'Ensure vendor/seccomp/{x64,arm64}/apply-seccomp binaries are included in the package.');
|
||||
}
|
||||
const applySeccompCmd = shellquote.quote([
|
||||
applySeccompBinary,
|
||||
seccompFilterPath,
|
||||
shellPath,
|
||||
'-c',
|
||||
userCommand,
|
||||
]);
|
||||
const innerScript = [...socatCommands, applySeccompCmd].join('\n');
|
||||
return `${shellPath} -c ${shellquote.quote([innerScript])}`;
|
||||
}
|
||||
else {
|
||||
// No seccomp filter - run user command directly
|
||||
const innerScript = [
|
||||
...socatCommands,
|
||||
`eval ${shellquote.quote([userCommand])}`,
|
||||
].join('\n');
|
||||
return `${shellPath} -c ${shellquote.quote([innerScript])}`;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate filesystem bind mount arguments for bwrap
|
||||
*/
|
||||
async function generateFilesystemArgs(readConfig, writeConfig, ripgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, abortSignal) {
|
||||
const args = [];
|
||||
// fs already imported
|
||||
// Determine initial root mount based on write restrictions
|
||||
if (writeConfig) {
|
||||
// Write restrictions: Start with read-only root, then allow writes to specific paths
|
||||
args.push('--ro-bind', '/', '/');
|
||||
// Collect normalized allowed write paths for later checking
|
||||
const allowedWritePaths = [];
|
||||
// Allow writes to specific paths
|
||||
for (const pathPattern of writeConfig.allowOnly || []) {
|
||||
const normalizedPath = normalizePathForSandbox(pathPattern);
|
||||
logForDebugging(`[Sandbox Linux] Processing write path: ${pathPattern} -> ${normalizedPath}`);
|
||||
// Skip /dev/* paths since --dev /dev already handles them
|
||||
if (normalizedPath.startsWith('/dev/')) {
|
||||
logForDebugging(`[Sandbox Linux] Skipping /dev path: ${normalizedPath}`);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(normalizedPath)) {
|
||||
logForDebugging(`[Sandbox Linux] Skipping non-existent write path: ${normalizedPath}`);
|
||||
continue;
|
||||
}
|
||||
// Check if path is a symlink pointing outside expected boundaries
|
||||
// bwrap follows symlinks, so --bind on a symlink makes the target writable
|
||||
// This could unexpectedly expose paths the user didn't intend to allow
|
||||
try {
|
||||
const resolvedPath = fs.realpathSync(normalizedPath);
|
||||
// Trim trailing slashes before comparing: realpathSync never returns
|
||||
// a trailing slash, but normalizedPath may have one, which would cause
|
||||
// a false mismatch and incorrectly treat the path as a symlink.
|
||||
const normalizedForComparison = normalizedPath.replace(/\/+$/, '');
|
||||
if (resolvedPath !== normalizedForComparison &&
|
||||
isSymlinkOutsideBoundary(normalizedPath, resolvedPath)) {
|
||||
logForDebugging(`[Sandbox Linux] Skipping symlink write path pointing outside expected location: ${pathPattern} -> ${resolvedPath}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// realpathSync failed - path might not exist or be accessible, skip it
|
||||
logForDebugging(`[Sandbox Linux] Skipping write path that could not be resolved: ${normalizedPath}`);
|
||||
continue;
|
||||
}
|
||||
args.push('--bind', normalizedPath, normalizedPath);
|
||||
allowedWritePaths.push(normalizedPath);
|
||||
}
|
||||
// Deny writes within allowed paths (user-specified + mandatory denies)
|
||||
const denyPaths = [
|
||||
...(writeConfig.denyWithinAllow || []),
|
||||
...(await linuxGetMandatoryDenyPaths(ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal)),
|
||||
];
|
||||
for (const pathPattern of denyPaths) {
|
||||
const normalizedPath = normalizePathForSandbox(pathPattern);
|
||||
// Skip /dev/* paths since --dev /dev already handles them
|
||||
if (normalizedPath.startsWith('/dev/')) {
|
||||
continue;
|
||||
}
|
||||
// Check for symlinks in the path - if any parent component is a symlink,
|
||||
// mount /dev/null there to prevent symlink replacement attacks.
|
||||
// Attack scenario: .claude is a symlink to ./decoy/, attacker deletes
|
||||
// symlink and creates real .claude/settings.json with malicious hooks.
|
||||
const symlinkInPath = findSymlinkInPath(normalizedPath, allowedWritePaths);
|
||||
if (symlinkInPath) {
|
||||
args.push('--ro-bind', '/dev/null', symlinkInPath);
|
||||
logForDebugging(`[Sandbox Linux] Mounted /dev/null at symlink ${symlinkInPath} to prevent symlink replacement attack`);
|
||||
continue;
|
||||
}
|
||||
// Handle non-existent paths by mounting /dev/null to block creation.
|
||||
// Without this, a sandboxed process could mkdir+write a denied path that
|
||||
// doesn't exist yet, bypassing the deny rule entirely.
|
||||
//
|
||||
// bwrap creates empty files on the host as mount points for these binds.
|
||||
// We track them in bwrapMountPoints so cleanupBwrapMountPoints() can
|
||||
// remove them after the command exits.
|
||||
if (!fs.existsSync(normalizedPath)) {
|
||||
// Fix 1 (worktree): If any existing component in the deny path is a
|
||||
// file (not a directory), skip the deny entirely. You can't mkdir
|
||||
// under a file, so the deny path can never be created. This handles
|
||||
// git worktrees where .git is a file.
|
||||
if (hasFileAncestor(normalizedPath)) {
|
||||
logForDebugging(`[Sandbox Linux] Skipping deny path with file ancestor (cannot create paths under a file): ${normalizedPath}`);
|
||||
continue;
|
||||
}
|
||||
// Find the deepest existing ancestor directory
|
||||
let ancestorPath = path.dirname(normalizedPath);
|
||||
while (ancestorPath !== '/' && !fs.existsSync(ancestorPath)) {
|
||||
ancestorPath = path.dirname(ancestorPath);
|
||||
}
|
||||
// Only protect if the existing ancestor is within an allowed write path.
|
||||
// If not, the path is already read-only from --ro-bind / /.
|
||||
const ancestorIsWithinAllowedPath = allowedWritePaths.some(allowedPath => ancestorPath.startsWith(allowedPath + '/') ||
|
||||
ancestorPath === allowedPath ||
|
||||
normalizedPath.startsWith(allowedPath + '/'));
|
||||
if (ancestorIsWithinAllowedPath) {
|
||||
const firstNonExistent = findFirstNonExistentComponent(normalizedPath);
|
||||
// Fix 2: If firstNonExistent is an intermediate component (not the
|
||||
// leaf deny path itself), mount a read-only empty directory instead
|
||||
// of /dev/null. This prevents the component from appearing as a file
|
||||
// which breaks tools that expect to traverse it as a directory.
|
||||
if (firstNonExistent !== normalizedPath) {
|
||||
const emptyDir = fs.mkdtempSync(path.join(tmpdir(), 'claude-empty-'));
|
||||
args.push('--ro-bind', emptyDir, firstNonExistent);
|
||||
bwrapMountPoints.add(firstNonExistent);
|
||||
registerExitCleanupHandler();
|
||||
logForDebugging(`[Sandbox Linux] Mounted empty dir at ${firstNonExistent} to block creation of ${normalizedPath}`);
|
||||
}
|
||||
else {
|
||||
args.push('--ro-bind', '/dev/null', firstNonExistent);
|
||||
bwrapMountPoints.add(firstNonExistent);
|
||||
registerExitCleanupHandler();
|
||||
logForDebugging(`[Sandbox Linux] Mounted /dev/null at ${firstNonExistent} to block creation of ${normalizedPath}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
logForDebugging(`[Sandbox Linux] Skipping non-existent deny path not within allowed paths: ${normalizedPath}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Only add deny binding if this path is within an allowed write path
|
||||
// Otherwise it's already read-only from the initial --ro-bind / /
|
||||
const isWithinAllowedPath = allowedWritePaths.some(allowedPath => normalizedPath.startsWith(allowedPath + '/') ||
|
||||
normalizedPath === allowedPath);
|
||||
if (isWithinAllowedPath) {
|
||||
args.push('--ro-bind', normalizedPath, normalizedPath);
|
||||
}
|
||||
else {
|
||||
logForDebugging(`[Sandbox Linux] Skipping deny path not within allowed paths: ${normalizedPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// No write restrictions: Allow all writes
|
||||
args.push('--bind', '/', '/');
|
||||
}
|
||||
// Handle read restrictions by mounting tmpfs over denied paths
|
||||
const readDenyPaths = [...(readConfig?.denyOnly || [])];
|
||||
const readAllowPaths = (readConfig?.allowWithinDeny || []).map(p => normalizePathForSandbox(p));
|
||||
// Always hide /etc/ssh/ssh_config.d to avoid permission issues with OrbStack
|
||||
// SSH is very strict about config file permissions and ownership, and they can
|
||||
// appear wrong inside the sandbox causing "Bad owner or permissions" errors
|
||||
if (fs.existsSync('/etc/ssh/ssh_config.d')) {
|
||||
readDenyPaths.push('/etc/ssh/ssh_config.d');
|
||||
}
|
||||
for (const pathPattern of readDenyPaths) {
|
||||
const normalizedPath = normalizePathForSandbox(pathPattern);
|
||||
if (!fs.existsSync(normalizedPath)) {
|
||||
logForDebugging(`[Sandbox Linux] Skipping non-existent read deny path: ${normalizedPath}`);
|
||||
continue;
|
||||
}
|
||||
const readDenyStat = fs.statSync(normalizedPath);
|
||||
if (readDenyStat.isDirectory()) {
|
||||
args.push('--tmpfs', normalizedPath);
|
||||
// Re-allow specific paths within the denied directory (allowRead overrides denyRead).
|
||||
// After mounting tmpfs over the denied dir, bind back the allowed subdirectories
|
||||
// so they are readable again.
|
||||
for (const allowPath of readAllowPaths) {
|
||||
if (allowPath.startsWith(normalizedPath + '/') ||
|
||||
allowPath === normalizedPath) {
|
||||
if (!fs.existsSync(allowPath)) {
|
||||
logForDebugging(`[Sandbox Linux] Skipping non-existent read allow path: ${allowPath}`);
|
||||
continue;
|
||||
}
|
||||
// Bind the allowed path back over the tmpfs so it's readable
|
||||
args.push('--ro-bind', allowPath, allowPath);
|
||||
logForDebugging(`[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// For files, check if this specific file is re-allowed
|
||||
const isReAllowed = readAllowPaths.some(allowPath => normalizedPath === allowPath ||
|
||||
normalizedPath.startsWith(allowPath + '/'));
|
||||
if (isReAllowed) {
|
||||
logForDebugging(`[Sandbox Linux] Skipping read deny for re-allowed path: ${normalizedPath}`);
|
||||
continue;
|
||||
}
|
||||
// For files, bind /dev/null instead of tmpfs
|
||||
args.push('--ro-bind', '/dev/null', normalizedPath);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
/**
|
||||
* Wrap a command with sandbox restrictions on Linux
|
||||
*
|
||||
* UNIX SOCKET BLOCKING (APPLY-SECCOMP):
|
||||
* This implementation uses a custom apply-seccomp binary to block Unix domain socket
|
||||
* creation for user commands while allowing network infrastructure:
|
||||
*
|
||||
* Stage 1: Outer bwrap - Network and filesystem isolation (NO seccomp)
|
||||
* - Bubblewrap starts with isolated network namespace (--unshare-net)
|
||||
* - Bubblewrap applies PID namespace isolation (--unshare-pid and --proc)
|
||||
* - Filesystem restrictions are applied (read-only mounts, bind mounts, etc.)
|
||||
* - Socat processes start and connect to Unix socket bridges (can use socket(AF_UNIX, ...))
|
||||
*
|
||||
* Stage 2: apply-seccomp - Seccomp filter application (ONLY seccomp)
|
||||
* - apply-seccomp binary applies seccomp filter via prctl(PR_SET_SECCOMP)
|
||||
* - Sets PR_SET_NO_NEW_PRIVS to allow seccomp without root
|
||||
* - Execs user command with seccomp active (cannot create new Unix sockets)
|
||||
*
|
||||
* This solves the conflict between:
|
||||
* - Security: Blocking arbitrary Unix socket creation in user commands
|
||||
* - Functionality: Network sandboxing requires socat to call socket(AF_UNIX, ...) for bridge connections
|
||||
*
|
||||
* The seccomp-bpf filter blocks socket(AF_UNIX, ...) syscalls, preventing:
|
||||
* - Creating new Unix domain socket file descriptors
|
||||
*
|
||||
* Security limitations:
|
||||
* - Does NOT block operations (bind, connect, sendto, etc.) on inherited Unix socket FDs
|
||||
* - Does NOT prevent passing Unix socket FDs via SCM_RIGHTS
|
||||
* - For most sandboxing use cases, blocking socket creation is sufficient
|
||||
*
|
||||
* The filter allows:
|
||||
* - All TCP/UDP sockets (AF_INET, AF_INET6) for normal network operations
|
||||
* - All other syscalls
|
||||
*
|
||||
* PLATFORM NOTE:
|
||||
* The allowUnixSockets configuration is not path-based on Linux (unlike macOS)
|
||||
* because seccomp-bpf cannot inspect user-space memory to read socket paths.
|
||||
*
|
||||
* Requirements for seccomp filtering:
|
||||
* - Pre-built apply-seccomp binaries are included for x64 and ARM64
|
||||
* - Pre-generated BPF filters are included for x64 and ARM64
|
||||
* - Other architectures are not currently supported (no apply-seccomp binary available)
|
||||
* - To use sandboxing without Unix socket blocking on unsupported architectures,
|
||||
* set allowAllUnixSockets: true in your configuration
|
||||
* Dependencies are checked by checkLinuxDependencies() before enabling the sandbox.
|
||||
*/
|
||||
export async function wrapCommandWithSandboxLinux(params) {
|
||||
const { command, needsNetworkRestriction, httpSocketPath, socksSocketPath, httpProxyPort, socksProxyPort, readConfig, writeConfig, enableWeakerNestedSandbox, allowAllUnixSockets, binShell, ripgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, seccompConfig, abortSignal, } = params;
|
||||
// Determine if we have restrictions to apply
|
||||
// Read: denyOnly pattern - empty array means no restrictions
|
||||
// Write: allowOnly pattern - undefined means no restrictions, any config means restrictions
|
||||
const hasReadRestrictions = readConfig && readConfig.denyOnly.length > 0;
|
||||
const hasWriteRestrictions = writeConfig !== undefined;
|
||||
// Check if we need any sandboxing
|
||||
if (!needsNetworkRestriction &&
|
||||
!hasReadRestrictions &&
|
||||
!hasWriteRestrictions) {
|
||||
return command;
|
||||
}
|
||||
const bwrapArgs = ['--new-session', '--die-with-parent'];
|
||||
let seccompFilterPath = undefined;
|
||||
try {
|
||||
// ========== SECCOMP FILTER (Unix Socket Blocking) ==========
|
||||
// Use bwrap's --seccomp flag to apply BPF filter that blocks Unix socket creation
|
||||
//
|
||||
// NOTE: Seccomp filtering is only enabled when allowAllUnixSockets is false
|
||||
// (when true, Unix sockets are allowed)
|
||||
if (!allowAllUnixSockets) {
|
||||
seccompFilterPath =
|
||||
generateSeccompFilter(seccompConfig?.bpfPath) ?? undefined;
|
||||
const applySeccompBinary = getApplySeccompBinaryPath(seccompConfig?.applyPath);
|
||||
if (!seccompFilterPath || !applySeccompBinary) {
|
||||
// Seccomp binaries not found - warn but continue without unix socket blocking
|
||||
logForDebugging('[Sandbox Linux] Seccomp binaries not available - unix socket blocking disabled. ' +
|
||||
'Install @anthropic-ai/sandbox-runtime globally for full protection.', { level: 'warn' });
|
||||
// Clear the filter path so we don't try to use it
|
||||
seccompFilterPath = undefined;
|
||||
}
|
||||
else {
|
||||
// Track filter for cleanup and register exit handler
|
||||
// Only track runtime-generated filters (not pre-generated ones from vendor/)
|
||||
if (!seccompFilterPath.includes('/vendor/seccomp/')) {
|
||||
generatedSeccompFilters.add(seccompFilterPath);
|
||||
registerExitCleanupHandler();
|
||||
}
|
||||
logForDebugging('[Sandbox Linux] Generated seccomp BPF filter for Unix socket blocking');
|
||||
}
|
||||
}
|
||||
else {
|
||||
logForDebugging('[Sandbox Linux] Skipping seccomp filter - allowAllUnixSockets is enabled');
|
||||
}
|
||||
// ========== NETWORK RESTRICTIONS ==========
|
||||
if (needsNetworkRestriction) {
|
||||
// Always unshare network namespace to isolate network access
|
||||
// This removes all network interfaces, effectively blocking all network
|
||||
bwrapArgs.push('--unshare-net');
|
||||
// If proxy sockets are provided, bind them into the sandbox to allow
|
||||
// filtered network access through the proxy. If not provided, network
|
||||
// is completely blocked (empty allowedDomains = block all)
|
||||
if (httpSocketPath && socksSocketPath) {
|
||||
// Verify socket files still exist before trying to bind them
|
||||
if (!fs.existsSync(httpSocketPath)) {
|
||||
throw new Error(`Linux HTTP bridge socket does not exist: ${httpSocketPath}. ` +
|
||||
'The bridge process may have died. Try reinitializing the sandbox.');
|
||||
}
|
||||
if (!fs.existsSync(socksSocketPath)) {
|
||||
throw new Error(`Linux SOCKS bridge socket does not exist: ${socksSocketPath}. ` +
|
||||
'The bridge process may have died. Try reinitializing the sandbox.');
|
||||
}
|
||||
// Bind both sockets into the sandbox
|
||||
bwrapArgs.push('--bind', httpSocketPath, httpSocketPath);
|
||||
bwrapArgs.push('--bind', socksSocketPath, socksSocketPath);
|
||||
// Add proxy environment variables
|
||||
// HTTP_PROXY points to the socat listener inside the sandbox (port 3128)
|
||||
// which forwards to the Unix socket that bridges to the host's proxy server
|
||||
const proxyEnv = generateProxyEnvVars(3128, // Internal HTTP listener port
|
||||
1080);
|
||||
bwrapArgs.push(...proxyEnv.flatMap((env) => {
|
||||
const firstEq = env.indexOf('=');
|
||||
const key = env.slice(0, firstEq);
|
||||
const value = env.slice(firstEq + 1);
|
||||
return ['--setenv', key, value];
|
||||
}));
|
||||
// Add host proxy port environment variables for debugging/transparency
|
||||
// These show which host ports the Unix socket bridges connect to
|
||||
if (httpProxyPort !== undefined) {
|
||||
bwrapArgs.push('--setenv', 'CLAUDE_CODE_HOST_HTTP_PROXY_PORT', String(httpProxyPort));
|
||||
}
|
||||
if (socksProxyPort !== undefined) {
|
||||
bwrapArgs.push('--setenv', 'CLAUDE_CODE_HOST_SOCKS_PROXY_PORT', String(socksProxyPort));
|
||||
}
|
||||
}
|
||||
// If no sockets provided, network is completely blocked (--unshare-net without proxy)
|
||||
}
|
||||
// ========== FILESYSTEM RESTRICTIONS ==========
|
||||
const fsArgs = await generateFilesystemArgs(readConfig, writeConfig, ripgrepConfig, mandatoryDenySearchDepth, allowGitConfig, abortSignal);
|
||||
bwrapArgs.push(...fsArgs);
|
||||
// Always bind /dev
|
||||
bwrapArgs.push('--dev', '/dev');
|
||||
// ========== PID NAMESPACE ISOLATION ==========
|
||||
// IMPORTANT: These must come AFTER filesystem binds for nested bwrap to work
|
||||
// By default, always unshare PID namespace and mount fresh /proc.
|
||||
// If we don't have --unshare-pid, it is possible to escape the sandbox.
|
||||
// If we don't have --proc, it is possible to read host /proc and leak information about code running
|
||||
// outside the sandbox. But, --proc is not available when running in unprivileged docker containers
|
||||
// so we support running without it if explicitly requested.
|
||||
bwrapArgs.push('--unshare-pid');
|
||||
if (!enableWeakerNestedSandbox) {
|
||||
// Mount fresh /proc if PID namespace is isolated (secure mode)
|
||||
bwrapArgs.push('--proc', '/proc');
|
||||
}
|
||||
// ========== COMMAND ==========
|
||||
// Use the user's shell (zsh, bash, etc.) to ensure aliases/snapshots work
|
||||
// Resolve the full path to the shell binary since bwrap doesn't use $PATH
|
||||
const shellName = binShell || 'bash';
|
||||
const shell = whichSync(shellName);
|
||||
if (!shell) {
|
||||
throw new Error(`Shell '${shellName}' not found in PATH`);
|
||||
}
|
||||
bwrapArgs.push('--', shell, '-c');
|
||||
// If we have network restrictions, use the network bridge setup with apply-seccomp for seccomp
|
||||
// Otherwise, just run the command directly with apply-seccomp if needed
|
||||
if (needsNetworkRestriction && httpSocketPath && socksSocketPath) {
|
||||
// Pass seccomp filter to buildSandboxCommand for apply-seccomp application
|
||||
// This allows socat to start before seccomp is applied
|
||||
const sandboxCommand = buildSandboxCommand(httpSocketPath, socksSocketPath, command, seccompFilterPath, shell, seccompConfig?.applyPath);
|
||||
bwrapArgs.push(sandboxCommand);
|
||||
}
|
||||
else if (seccompFilterPath) {
|
||||
// No network restrictions but we have seccomp - use apply-seccomp directly
|
||||
// apply-seccomp is a simple C program that applies the seccomp filter and execs the command
|
||||
const applySeccompBinary = getApplySeccompBinaryPath(seccompConfig?.applyPath);
|
||||
if (!applySeccompBinary) {
|
||||
throw new Error('apply-seccomp binary not found. This should have been caught earlier. ' +
|
||||
'Ensure vendor/seccomp/{x64,arm64}/apply-seccomp binaries are included in the package.');
|
||||
}
|
||||
const applySeccompCmd = shellquote.quote([
|
||||
applySeccompBinary,
|
||||
seccompFilterPath,
|
||||
shell,
|
||||
'-c',
|
||||
command,
|
||||
]);
|
||||
bwrapArgs.push(applySeccompCmd);
|
||||
}
|
||||
else {
|
||||
bwrapArgs.push(command);
|
||||
}
|
||||
// Build the outer bwrap command
|
||||
const wrappedCommand = shellquote.quote(['bwrap', ...bwrapArgs]);
|
||||
const restrictions = [];
|
||||
if (needsNetworkRestriction)
|
||||
restrictions.push('network');
|
||||
if (hasReadRestrictions || hasWriteRestrictions)
|
||||
restrictions.push('filesystem');
|
||||
if (seccompFilterPath)
|
||||
restrictions.push('seccomp(unix-block)');
|
||||
logForDebugging(`[Sandbox Linux] Wrapped command with bwrap (${restrictions.join(', ')} restrictions)`);
|
||||
return wrappedCommand;
|
||||
}
|
||||
catch (error) {
|
||||
// Clean up seccomp filter on error
|
||||
if (seccompFilterPath && !seccompFilterPath.includes('/vendor/seccomp/')) {
|
||||
generatedSeccompFilters.delete(seccompFilterPath);
|
||||
try {
|
||||
cleanupSeccompFilter(seccompFilterPath);
|
||||
}
|
||||
catch (cleanupError) {
|
||||
logForDebugging(`[Sandbox Linux] Failed to clean up seccomp filter on error: ${cleanupError}`, { level: 'error' });
|
||||
}
|
||||
}
|
||||
// Re-throw the original error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=linux-sandbox-utils.js.map
|
||||
630
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/macos-sandbox-utils.js
vendored
Normal file
630
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/macos-sandbox-utils.js
vendored
Normal file
@@ -0,0 +1,630 @@
|
||||
import shellquote from 'shell-quote';
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
import { whichSync } from '../utils/which.js';
|
||||
import { normalizePathForSandbox, generateProxyEnvVars, encodeSandboxedCommand, decodeSandboxedCommand, containsGlobChars, globToRegex, DANGEROUS_FILES, getDangerousDirectories, } from './sandbox-utils.js';
|
||||
/**
|
||||
* Get mandatory deny patterns as glob patterns (no filesystem scanning).
|
||||
* macOS sandbox profile supports regex/glob matching directly via globToRegex().
|
||||
*/
|
||||
export function macGetMandatoryDenyPatterns(allowGitConfig = false) {
|
||||
const cwd = process.cwd();
|
||||
const denyPaths = [];
|
||||
// Dangerous files - static paths in CWD + glob patterns for subtree
|
||||
for (const fileName of DANGEROUS_FILES) {
|
||||
denyPaths.push(path.resolve(cwd, fileName));
|
||||
denyPaths.push(`**/${fileName}`);
|
||||
}
|
||||
// Dangerous directories
|
||||
for (const dirName of getDangerousDirectories()) {
|
||||
denyPaths.push(path.resolve(cwd, dirName));
|
||||
denyPaths.push(`**/${dirName}/**`);
|
||||
}
|
||||
// Git hooks are always blocked for security
|
||||
denyPaths.push(path.resolve(cwd, '.git/hooks'));
|
||||
denyPaths.push('**/.git/hooks/**');
|
||||
// Git config - conditionally blocked based on allowGitConfig setting
|
||||
if (!allowGitConfig) {
|
||||
denyPaths.push(path.resolve(cwd, '.git/config'));
|
||||
denyPaths.push('**/.git/config');
|
||||
}
|
||||
return [...new Set(denyPaths)];
|
||||
}
|
||||
const sessionSuffix = `_${Math.random().toString(36).slice(2, 11)}_SBX`;
|
||||
/**
|
||||
* Generate a unique log tag for sandbox monitoring
|
||||
* @param command - The command being executed (will be base64 encoded)
|
||||
*/
|
||||
function generateLogTag(command) {
|
||||
const encodedCommand = encodeSandboxedCommand(command);
|
||||
return `CMD64_${encodedCommand}_END_${sessionSuffix}`;
|
||||
}
|
||||
/**
|
||||
* Get all ancestor directories for a path, up to (but not including) root
|
||||
* Example: /private/tmp/test/file.txt -> ["/private/tmp/test", "/private/tmp", "/private"]
|
||||
*/
|
||||
function getAncestorDirectories(pathStr) {
|
||||
const ancestors = [];
|
||||
let currentPath = path.dirname(pathStr);
|
||||
// Walk up the directory tree until we reach root
|
||||
while (currentPath !== '/' && currentPath !== '.') {
|
||||
ancestors.push(currentPath);
|
||||
const parentPath = path.dirname(currentPath);
|
||||
// Break if we've reached the top (path.dirname returns the same path for root)
|
||||
if (parentPath === currentPath) {
|
||||
break;
|
||||
}
|
||||
currentPath = parentPath;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
/**
|
||||
* Generate deny rules for file movement (file-write-unlink) to protect paths
|
||||
* This prevents bypassing read or write restrictions by moving files/directories
|
||||
*
|
||||
* @param pathPatterns - Array of path patterns to protect (can include globs)
|
||||
* @param logTag - Log tag for sandbox violations
|
||||
* @returns Array of sandbox profile rule lines
|
||||
*/
|
||||
function generateMoveBlockingRules(pathPatterns, logTag) {
|
||||
const rules = [];
|
||||
for (const pathPattern of pathPatterns) {
|
||||
const normalizedPath = normalizePathForSandbox(pathPattern);
|
||||
if (containsGlobChars(normalizedPath)) {
|
||||
// Use regex matching for glob patterns
|
||||
const regexPattern = globToRegex(normalizedPath);
|
||||
// Block moving/renaming files matching this pattern
|
||||
rules.push(`(deny file-write-unlink`, ` (regex ${escapePath(regexPattern)})`, ` (with message "${logTag}"))`);
|
||||
// For glob patterns, extract the static prefix and block ancestor moves
|
||||
// Remove glob characters to get the directory prefix
|
||||
const staticPrefix = normalizedPath.split(/[*?[\]]/)[0];
|
||||
if (staticPrefix && staticPrefix !== '/') {
|
||||
// Get the directory containing the glob pattern
|
||||
const baseDir = staticPrefix.endsWith('/')
|
||||
? staticPrefix.slice(0, -1)
|
||||
: path.dirname(staticPrefix);
|
||||
// Block moves of the base directory itself
|
||||
rules.push(`(deny file-write-unlink`, ` (literal ${escapePath(baseDir)})`, ` (with message "${logTag}"))`);
|
||||
// Block moves of ancestor directories
|
||||
for (const ancestorDir of getAncestorDirectories(baseDir)) {
|
||||
rules.push(`(deny file-write-unlink`, ` (literal ${escapePath(ancestorDir)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Use subpath matching for literal paths
|
||||
// Block moving/renaming the denied path itself
|
||||
rules.push(`(deny file-write-unlink`, ` (subpath ${escapePath(normalizedPath)})`, ` (with message "${logTag}"))`);
|
||||
// Block moves of ancestor directories
|
||||
for (const ancestorDir of getAncestorDirectories(normalizedPath)) {
|
||||
rules.push(`(deny file-write-unlink`, ` (literal ${escapePath(ancestorDir)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
/**
|
||||
* Generate filesystem read rules for sandbox profile
|
||||
*
|
||||
* Supports two layers:
|
||||
* 1. denyOnly: deny reads from these paths (broad regions like /Users)
|
||||
* 2. allowWithinDeny: re-allow reads within denied regions (like CWD)
|
||||
* allowWithinDeny takes precedence over denyOnly.
|
||||
*
|
||||
* In Seatbelt profiles, later rules take precedence, so we emit:
|
||||
* (allow file-read*) ← default: allow everything
|
||||
* (deny file-read* ...) ← deny broad regions
|
||||
* (allow file-read* ...) ← re-allow specific paths within denied regions
|
||||
*/
|
||||
function generateReadRules(config, logTag) {
|
||||
if (!config) {
|
||||
return [`(allow file-read*)`];
|
||||
}
|
||||
const rules = [];
|
||||
// Start by allowing everything
|
||||
rules.push(`(allow file-read*)`);
|
||||
// Then deny specific paths
|
||||
for (const pathPattern of config.denyOnly || []) {
|
||||
const normalizedPath = normalizePathForSandbox(pathPattern);
|
||||
if (containsGlobChars(normalizedPath)) {
|
||||
// Use regex matching for glob patterns
|
||||
const regexPattern = globToRegex(normalizedPath);
|
||||
rules.push(`(deny file-read*`, ` (regex ${escapePath(regexPattern)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
else {
|
||||
// Use subpath matching for literal paths
|
||||
rules.push(`(deny file-read*`, ` (subpath ${escapePath(normalizedPath)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
}
|
||||
// Re-allow specific paths within denied regions (allowWithinDeny takes precedence)
|
||||
for (const pathPattern of config.allowWithinDeny || []) {
|
||||
const normalizedPath = normalizePathForSandbox(pathPattern);
|
||||
if (containsGlobChars(normalizedPath)) {
|
||||
const regexPattern = globToRegex(normalizedPath);
|
||||
rules.push(`(allow file-read*`, ` (regex ${escapePath(regexPattern)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
else {
|
||||
rules.push(`(allow file-read*`, ` (subpath ${escapePath(normalizedPath)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
}
|
||||
// Allow stat/lstat on all directories so that realpath() can traverse
|
||||
// path components within denied regions. Without this, C realpath() fails
|
||||
// when resolving symlinks because it needs to lstat every intermediate
|
||||
// directory (e.g. /Users, /Users/chris) even if only a subdirectory like
|
||||
// ~/.local is in allowWithinDeny. This only allows metadata reads on
|
||||
// directories — not listing contents (readdir) or reading files.
|
||||
if ((config.denyOnly).length > 0) {
|
||||
rules.push(`(allow file-read-metadata`, ` (vnode-type DIRECTORY))`);
|
||||
}
|
||||
// Block file movement to prevent bypass via mv/rename
|
||||
rules.push(...generateMoveBlockingRules(config.denyOnly || [], logTag));
|
||||
return rules;
|
||||
}
|
||||
/**
|
||||
* Generate filesystem write rules for sandbox profile
|
||||
*/
|
||||
function generateWriteRules(config, logTag, allowGitConfig = false) {
|
||||
if (!config) {
|
||||
return [`(allow file-write*)`];
|
||||
}
|
||||
const rules = [];
|
||||
// Automatically allow TMPDIR parent on macOS when write restrictions are enabled
|
||||
const tmpdirParents = getTmpdirParentIfMacOSPattern();
|
||||
for (const tmpdirParent of tmpdirParents) {
|
||||
const normalizedPath = normalizePathForSandbox(tmpdirParent);
|
||||
rules.push(`(allow file-write*`, ` (subpath ${escapePath(normalizedPath)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
// Generate allow rules
|
||||
for (const pathPattern of config.allowOnly || []) {
|
||||
const normalizedPath = normalizePathForSandbox(pathPattern);
|
||||
if (containsGlobChars(normalizedPath)) {
|
||||
// Use regex matching for glob patterns
|
||||
const regexPattern = globToRegex(normalizedPath);
|
||||
rules.push(`(allow file-write*`, ` (regex ${escapePath(regexPattern)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
else {
|
||||
// Use subpath matching for literal paths
|
||||
rules.push(`(allow file-write*`, ` (subpath ${escapePath(normalizedPath)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
}
|
||||
// Combine user-specified and mandatory deny patterns (no ripgrep needed on macOS)
|
||||
const denyPaths = [
|
||||
...(config.denyWithinAllow || []),
|
||||
...macGetMandatoryDenyPatterns(allowGitConfig),
|
||||
];
|
||||
for (const pathPattern of denyPaths) {
|
||||
const normalizedPath = normalizePathForSandbox(pathPattern);
|
||||
if (containsGlobChars(normalizedPath)) {
|
||||
// Use regex matching for glob patterns
|
||||
const regexPattern = globToRegex(normalizedPath);
|
||||
rules.push(`(deny file-write*`, ` (regex ${escapePath(regexPattern)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
else {
|
||||
// Use subpath matching for literal paths
|
||||
rules.push(`(deny file-write*`, ` (subpath ${escapePath(normalizedPath)})`, ` (with message "${logTag}"))`);
|
||||
}
|
||||
}
|
||||
// Block file movement to prevent bypass via mv/rename
|
||||
rules.push(...generateMoveBlockingRules(denyPaths, logTag));
|
||||
return rules;
|
||||
}
|
||||
/**
|
||||
* Generate complete sandbox profile
|
||||
*/
|
||||
function generateSandboxProfile({ readConfig, writeConfig, httpProxyPort, socksProxyPort, needsNetworkRestriction, allowUnixSockets, allowAllUnixSockets, allowLocalBinding, allowPty, allowGitConfig = false, enableWeakerNetworkIsolation = false, logTag, }) {
|
||||
const profile = [
|
||||
'(version 1)',
|
||||
`(deny default (with message "${logTag}"))`,
|
||||
'',
|
||||
`; LogTag: ${logTag}`,
|
||||
'',
|
||||
'; Essential permissions - based on Chrome sandbox policy',
|
||||
'; Process permissions',
|
||||
'(allow process-exec)',
|
||||
'(allow process-fork)',
|
||||
'(allow process-info* (target same-sandbox))',
|
||||
'(allow signal (target same-sandbox))',
|
||||
'(allow mach-priv-task-port (target same-sandbox))',
|
||||
'',
|
||||
'; User preferences',
|
||||
'(allow user-preference-read)',
|
||||
'',
|
||||
'; Mach IPC - specific services only (no wildcard)',
|
||||
'(allow mach-lookup',
|
||||
' (global-name "com.apple.audio.systemsoundserver")',
|
||||
' (global-name "com.apple.distributed_notifications@Uv3")',
|
||||
' (global-name "com.apple.FontObjectsServer")',
|
||||
' (global-name "com.apple.fonts")',
|
||||
' (global-name "com.apple.logd")',
|
||||
' (global-name "com.apple.lsd.mapdb")',
|
||||
' (global-name "com.apple.PowerManagement.control")',
|
||||
' (global-name "com.apple.system.logger")',
|
||||
' (global-name "com.apple.system.notification_center")',
|
||||
' (global-name "com.apple.system.opendirectoryd.libinfo")',
|
||||
' (global-name "com.apple.system.opendirectoryd.membership")',
|
||||
' (global-name "com.apple.bsd.dirhelper")',
|
||||
' (global-name "com.apple.securityd.xpc")',
|
||||
' (global-name "com.apple.coreservices.launchservicesd")',
|
||||
')',
|
||||
'',
|
||||
...(enableWeakerNetworkIsolation
|
||||
? [
|
||||
'; trustd.agent - needed for Go TLS certificate verification (weaker network isolation)',
|
||||
'(allow mach-lookup (global-name "com.apple.trustd.agent"))',
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
'; POSIX IPC - shared memory',
|
||||
'(allow ipc-posix-shm)',
|
||||
'',
|
||||
'; POSIX IPC - semaphores for Python multiprocessing',
|
||||
'(allow ipc-posix-sem)',
|
||||
'',
|
||||
'; IOKit - specific operations only',
|
||||
'(allow iokit-open',
|
||||
' (iokit-registry-entry-class "IOSurfaceRootUserClient")',
|
||||
' (iokit-registry-entry-class "RootDomainUserClient")',
|
||||
' (iokit-user-client-class "IOSurfaceSendRight")',
|
||||
')',
|
||||
'',
|
||||
'; IOKit properties',
|
||||
'(allow iokit-get-properties)',
|
||||
'',
|
||||
"; Specific safe system-sockets, doesn't allow network access",
|
||||
'(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))',
|
||||
'',
|
||||
'; sysctl - specific sysctls only',
|
||||
'(allow sysctl-read',
|
||||
' (sysctl-name "hw.activecpu")',
|
||||
' (sysctl-name "hw.busfrequency_compat")',
|
||||
' (sysctl-name "hw.byteorder")',
|
||||
' (sysctl-name "hw.cacheconfig")',
|
||||
' (sysctl-name "hw.cachelinesize_compat")',
|
||||
' (sysctl-name "hw.cpufamily")',
|
||||
' (sysctl-name "hw.cpufrequency")',
|
||||
' (sysctl-name "hw.cpufrequency_compat")',
|
||||
' (sysctl-name "hw.cputype")',
|
||||
' (sysctl-name "hw.l1dcachesize_compat")',
|
||||
' (sysctl-name "hw.l1icachesize_compat")',
|
||||
' (sysctl-name "hw.l2cachesize_compat")',
|
||||
' (sysctl-name "hw.l3cachesize_compat")',
|
||||
' (sysctl-name "hw.logicalcpu")',
|
||||
' (sysctl-name "hw.logicalcpu_max")',
|
||||
' (sysctl-name "hw.machine")',
|
||||
' (sysctl-name "hw.memsize")',
|
||||
' (sysctl-name "hw.ncpu")',
|
||||
' (sysctl-name "hw.nperflevels")',
|
||||
' (sysctl-name "hw.packages")',
|
||||
' (sysctl-name "hw.pagesize_compat")',
|
||||
' (sysctl-name "hw.pagesize")',
|
||||
' (sysctl-name "hw.physicalcpu")',
|
||||
' (sysctl-name "hw.physicalcpu_max")',
|
||||
' (sysctl-name "hw.tbfrequency_compat")',
|
||||
' (sysctl-name "hw.vectorunit")',
|
||||
' (sysctl-name "kern.argmax")',
|
||||
' (sysctl-name "kern.bootargs")',
|
||||
' (sysctl-name "kern.hostname")',
|
||||
' (sysctl-name "kern.maxfiles")',
|
||||
' (sysctl-name "kern.maxfilesperproc")',
|
||||
' (sysctl-name "kern.maxproc")',
|
||||
' (sysctl-name "kern.ngroups")',
|
||||
' (sysctl-name "kern.osproductversion")',
|
||||
' (sysctl-name "kern.osrelease")',
|
||||
' (sysctl-name "kern.ostype")',
|
||||
' (sysctl-name "kern.osvariant_status")',
|
||||
' (sysctl-name "kern.osversion")',
|
||||
' (sysctl-name "kern.secure_kernel")',
|
||||
' (sysctl-name "kern.tcsm_available")',
|
||||
' (sysctl-name "kern.tcsm_enable")',
|
||||
' (sysctl-name "kern.usrstack64")',
|
||||
' (sysctl-name "kern.version")',
|
||||
' (sysctl-name "kern.willshutdown")',
|
||||
' (sysctl-name "machdep.cpu.brand_string")',
|
||||
' (sysctl-name "machdep.ptrauth_enabled")',
|
||||
' (sysctl-name "security.mac.lockdown_mode_state")',
|
||||
' (sysctl-name "sysctl.proc_cputype")',
|
||||
' (sysctl-name "vm.loadavg")',
|
||||
' (sysctl-name-prefix "hw.optional.arm")',
|
||||
' (sysctl-name-prefix "hw.optional.arm.")',
|
||||
' (sysctl-name-prefix "hw.optional.armv8_")',
|
||||
' (sysctl-name-prefix "hw.perflevel")',
|
||||
' (sysctl-name-prefix "kern.proc.all")',
|
||||
' (sysctl-name-prefix "kern.proc.pgrp.")',
|
||||
' (sysctl-name-prefix "kern.proc.pid.")',
|
||||
' (sysctl-name-prefix "machdep.cpu.")',
|
||||
' (sysctl-name-prefix "net.routetable.")',
|
||||
')',
|
||||
'',
|
||||
'; V8 thread calculations',
|
||||
'(allow sysctl-write',
|
||||
' (sysctl-name "kern.tcsm_enable")',
|
||||
')',
|
||||
'',
|
||||
'; Distributed notifications',
|
||||
'(allow distributed-notification-post)',
|
||||
'',
|
||||
'; Specific mach-lookup permissions for security operations',
|
||||
'(allow mach-lookup (global-name "com.apple.SecurityServer"))',
|
||||
'',
|
||||
'; File I/O on device files',
|
||||
'(allow file-ioctl (literal "/dev/null"))',
|
||||
'(allow file-ioctl (literal "/dev/zero"))',
|
||||
'(allow file-ioctl (literal "/dev/random"))',
|
||||
'(allow file-ioctl (literal "/dev/urandom"))',
|
||||
'(allow file-ioctl (literal "/dev/dtracehelper"))',
|
||||
'(allow file-ioctl (literal "/dev/tty"))',
|
||||
'',
|
||||
'(allow file-ioctl file-read-data file-write-data',
|
||||
' (require-all',
|
||||
' (literal "/dev/null")',
|
||||
' (vnode-type CHARACTER-DEVICE)',
|
||||
' )',
|
||||
')',
|
||||
'',
|
||||
];
|
||||
// Network rules
|
||||
profile.push('; Network');
|
||||
if (!needsNetworkRestriction) {
|
||||
profile.push('(allow network*)');
|
||||
}
|
||||
else {
|
||||
// Allow local binding if requested
|
||||
// Use "*:*" instead of "localhost:*" because modern runtimes (Java, etc.) create
|
||||
// IPv6 dual-stack sockets by default. When binding such a socket to 127.0.0.1,
|
||||
// the kernel represents it as ::ffff:127.0.0.1 (IPv4-mapped IPv6). Seatbelt's
|
||||
// "localhost" filter only matches 127.0.0.1 and ::1, NOT ::ffff:127.0.0.1.
|
||||
// Using (local ip "*:*") is safe because it only matches the LOCAL endpoint —
|
||||
// internet-bound connections originate from non-loopback interfaces, so they
|
||||
// remain blocked by (deny default).
|
||||
if (allowLocalBinding) {
|
||||
profile.push('(allow network-bind (local ip "*:*"))');
|
||||
profile.push('(allow network-inbound (local ip "*:*"))');
|
||||
profile.push('(allow network-outbound (local ip "*:*"))');
|
||||
}
|
||||
// Unix domain sockets for local IPC (SSH agent, Docker, Gradle, etc.)
|
||||
// Three separate operations must be allowed:
|
||||
// 1. system-socket: socket(AF_UNIX, ...) syscall — creates the socket fd (no path context)
|
||||
// 2. network-bind: bind() to a local Unix socket path
|
||||
// 3. network-outbound: connect() to a remote Unix socket path
|
||||
// Note: (subpath ...) and (path-regex ...) are path-based filters that can only match
|
||||
// bind/connect operations — socket() creation has no path, so it requires system-socket.
|
||||
if (allowAllUnixSockets) {
|
||||
// Allow creating AF_UNIX sockets and all Unix socket paths
|
||||
profile.push('(allow system-socket (socket-domain AF_UNIX))');
|
||||
profile.push('(allow network-bind (local unix-socket (path-regex #"^/")))');
|
||||
profile.push('(allow network-outbound (remote unix-socket (path-regex #"^/")))');
|
||||
}
|
||||
else if (allowUnixSockets && allowUnixSockets.length > 0) {
|
||||
// Allow creating AF_UNIX sockets (required for any Unix socket use)
|
||||
profile.push('(allow system-socket (socket-domain AF_UNIX))');
|
||||
// Allow specific Unix socket paths
|
||||
for (const socketPath of allowUnixSockets) {
|
||||
const normalizedPath = normalizePathForSandbox(socketPath);
|
||||
profile.push(`(allow network-bind (local unix-socket (subpath ${escapePath(normalizedPath)})))`);
|
||||
profile.push(`(allow network-outbound (remote unix-socket (subpath ${escapePath(normalizedPath)})))`);
|
||||
}
|
||||
}
|
||||
// If both allowAllUnixSockets and allowUnixSockets are false/undefined/empty, Unix sockets are blocked by default
|
||||
// Allow localhost TCP operations for the HTTP proxy
|
||||
if (httpProxyPort !== undefined) {
|
||||
profile.push(`(allow network-bind (local ip "localhost:${httpProxyPort}"))`);
|
||||
profile.push(`(allow network-inbound (local ip "localhost:${httpProxyPort}"))`);
|
||||
profile.push(`(allow network-outbound (remote ip "localhost:${httpProxyPort}"))`);
|
||||
}
|
||||
// Allow localhost TCP operations for the SOCKS proxy
|
||||
if (socksProxyPort !== undefined) {
|
||||
profile.push(`(allow network-bind (local ip "localhost:${socksProxyPort}"))`);
|
||||
profile.push(`(allow network-inbound (local ip "localhost:${socksProxyPort}"))`);
|
||||
profile.push(`(allow network-outbound (remote ip "localhost:${socksProxyPort}"))`);
|
||||
}
|
||||
}
|
||||
profile.push('');
|
||||
// Read rules
|
||||
profile.push('; File read');
|
||||
profile.push(...generateReadRules(readConfig, logTag));
|
||||
profile.push('');
|
||||
// Write rules
|
||||
profile.push('; File write');
|
||||
profile.push(...generateWriteRules(writeConfig, logTag, allowGitConfig));
|
||||
// Pseudo-terminal (pty) support
|
||||
if (allowPty) {
|
||||
profile.push('');
|
||||
profile.push('; Pseudo-terminal (pty) support');
|
||||
profile.push('(allow pseudo-tty)');
|
||||
profile.push('(allow file-ioctl');
|
||||
profile.push(' (literal "/dev/ptmx")');
|
||||
profile.push(' (regex #"^/dev/ttys")');
|
||||
profile.push(')');
|
||||
profile.push('(allow file-read* file-write*');
|
||||
profile.push(' (literal "/dev/ptmx")');
|
||||
profile.push(' (regex #"^/dev/ttys")');
|
||||
profile.push(')');
|
||||
}
|
||||
return profile.join('\n');
|
||||
}
|
||||
/**
|
||||
* Escape path for sandbox profile using JSON.stringify for proper escaping
|
||||
*/
|
||||
function escapePath(pathStr) {
|
||||
return JSON.stringify(pathStr);
|
||||
}
|
||||
/**
|
||||
* Get TMPDIR parent directory if it matches macOS pattern /var/folders/XX/YYY/T/
|
||||
* Returns both /var/ and /private/var/ versions since /var is a symlink
|
||||
*/
|
||||
function getTmpdirParentIfMacOSPattern() {
|
||||
const tmpdir = process.env.TMPDIR;
|
||||
if (!tmpdir)
|
||||
return [];
|
||||
const match = tmpdir.match(/^\/(private\/)?var\/folders\/[^/]{2}\/[^/]+\/T\/?$/);
|
||||
if (!match)
|
||||
return [];
|
||||
const parent = tmpdir.replace(/\/T\/?$/, '');
|
||||
// Return both /var/ and /private/var/ versions since /var is a symlink
|
||||
if (parent.startsWith('/private/var/')) {
|
||||
return [parent, parent.replace('/private', '')];
|
||||
}
|
||||
else if (parent.startsWith('/var/')) {
|
||||
return [parent, '/private' + parent];
|
||||
}
|
||||
return [parent];
|
||||
}
|
||||
/**
|
||||
* Wrap command with macOS sandbox
|
||||
*/
|
||||
export function wrapCommandWithSandboxMacOS(params) {
|
||||
const { command, needsNetworkRestriction, httpProxyPort, socksProxyPort, allowUnixSockets, allowAllUnixSockets, allowLocalBinding, readConfig, writeConfig, allowPty, allowGitConfig = false, enableWeakerNetworkIsolation = false, binShell, } = params;
|
||||
// Determine if we have restrictions to apply
|
||||
// Read: denyOnly pattern - empty array means no restrictions
|
||||
// Write: allowOnly pattern - undefined means no restrictions, any config means restrictions
|
||||
const hasReadRestrictions = readConfig && readConfig.denyOnly.length > 0;
|
||||
const hasWriteRestrictions = writeConfig !== undefined;
|
||||
// No sandboxing needed
|
||||
if (!needsNetworkRestriction &&
|
||||
!hasReadRestrictions &&
|
||||
!hasWriteRestrictions) {
|
||||
return command;
|
||||
}
|
||||
const logTag = generateLogTag(command);
|
||||
const profile = generateSandboxProfile({
|
||||
readConfig,
|
||||
writeConfig,
|
||||
httpProxyPort,
|
||||
socksProxyPort,
|
||||
needsNetworkRestriction,
|
||||
allowUnixSockets,
|
||||
allowAllUnixSockets,
|
||||
allowLocalBinding,
|
||||
allowPty,
|
||||
allowGitConfig,
|
||||
enableWeakerNetworkIsolation,
|
||||
logTag,
|
||||
});
|
||||
// Generate proxy environment variables using shared utility
|
||||
const proxyEnvArgs = generateProxyEnvVars(httpProxyPort, socksProxyPort);
|
||||
// Use the user's shell (zsh, bash, etc.) to ensure aliases/snapshots work
|
||||
// Resolve the full path to the shell binary
|
||||
const shellName = binShell || 'bash';
|
||||
const shell = whichSync(shellName);
|
||||
if (!shell) {
|
||||
throw new Error(`Shell '${shellName}' not found in PATH`);
|
||||
}
|
||||
// Use `env` command to set environment variables - each VAR=value is a separate
|
||||
// argument that shellquote handles properly, avoiding shell quoting issues
|
||||
const wrappedCommand = shellquote.quote([
|
||||
'env',
|
||||
...proxyEnvArgs,
|
||||
'sandbox-exec',
|
||||
'-p',
|
||||
profile,
|
||||
shell,
|
||||
'-c',
|
||||
command,
|
||||
]);
|
||||
logForDebugging(`[Sandbox macOS] Applied restrictions - network: ${!!(httpProxyPort || socksProxyPort)}, read: ${readConfig
|
||||
? 'allowAllExcept' in readConfig
|
||||
? 'allowAllExcept'
|
||||
: 'denyAllExcept'
|
||||
: 'none'}, write: ${writeConfig
|
||||
? 'allowAllExcept' in writeConfig
|
||||
? 'allowAllExcept'
|
||||
: 'denyAllExcept'
|
||||
: 'none'}`);
|
||||
return wrappedCommand;
|
||||
}
|
||||
/**
|
||||
* Start monitoring macOS system logs for sandbox violations
|
||||
* Look for sandbox-related kernel deny events ending in {logTag}
|
||||
*/
|
||||
export function startMacOSSandboxLogMonitor(callback, ignoreViolations) {
|
||||
// Pre-compile regex patterns for better performance
|
||||
const cmdExtractRegex = /CMD64_(.+?)_END/;
|
||||
const sandboxExtractRegex = /Sandbox:\s+(.+)$/;
|
||||
// Pre-process ignore patterns for faster lookup
|
||||
const wildcardPaths = ignoreViolations?.['*'] || [];
|
||||
const commandPatterns = ignoreViolations
|
||||
? Object.entries(ignoreViolations).filter(([pattern]) => pattern !== '*')
|
||||
: [];
|
||||
// Stream and filter kernel logs for all sandbox violations
|
||||
// We can't filter by specific logTag since it's dynamic per command
|
||||
const logProcess = spawn('log', [
|
||||
'stream',
|
||||
'--predicate',
|
||||
`(eventMessage ENDSWITH "${sessionSuffix}")`,
|
||||
'--style',
|
||||
'compact',
|
||||
]);
|
||||
logProcess.stdout?.on('data', (data) => {
|
||||
const lines = data.toString().split('\n');
|
||||
// Get violation and command lines
|
||||
const violationLine = lines.find(line => line.includes('Sandbox:') && line.includes('deny'));
|
||||
const commandLine = lines.find(line => line.startsWith('CMD64_'));
|
||||
if (!violationLine)
|
||||
return;
|
||||
// Extract violation details
|
||||
const sandboxMatch = violationLine.match(sandboxExtractRegex);
|
||||
if (!sandboxMatch?.[1])
|
||||
return;
|
||||
const violationDetails = sandboxMatch[1];
|
||||
// Try to get command
|
||||
let command;
|
||||
let encodedCommand;
|
||||
if (commandLine) {
|
||||
const cmdMatch = commandLine.match(cmdExtractRegex);
|
||||
encodedCommand = cmdMatch?.[1];
|
||||
if (encodedCommand) {
|
||||
try {
|
||||
command = decodeSandboxedCommand(encodedCommand);
|
||||
}
|
||||
catch {
|
||||
// Failed to decode, continue without command
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always filter out noisey violations
|
||||
if (violationDetails.includes('mDNSResponder') ||
|
||||
violationDetails.includes('mach-lookup com.apple.diagnosticd') ||
|
||||
violationDetails.includes('mach-lookup com.apple.analyticsd')) {
|
||||
return;
|
||||
}
|
||||
// Check if we should ignore this violation
|
||||
if (ignoreViolations && command) {
|
||||
// Check wildcard patterns first
|
||||
if (wildcardPaths.length > 0) {
|
||||
const shouldIgnore = wildcardPaths.some(path => violationDetails.includes(path));
|
||||
if (shouldIgnore)
|
||||
return;
|
||||
}
|
||||
// Check command-specific patterns
|
||||
for (const [pattern, paths] of commandPatterns) {
|
||||
if (command.includes(pattern)) {
|
||||
const shouldIgnore = paths.some(path => violationDetails.includes(path));
|
||||
if (shouldIgnore)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not ignored - report the violation
|
||||
callback({
|
||||
line: violationDetails,
|
||||
command,
|
||||
encodedCommand,
|
||||
timestamp: new Date(), // We could parse the timestamp from the log but this feels more reliable
|
||||
});
|
||||
});
|
||||
logProcess.stderr?.on('data', (data) => {
|
||||
logForDebugging(`[Sandbox Monitor] Log stream stderr: ${data.toString()}`);
|
||||
});
|
||||
logProcess.on('error', (error) => {
|
||||
logForDebugging(`[Sandbox Monitor] Failed to start log stream: ${error.message}`);
|
||||
});
|
||||
logProcess.on('exit', (code) => {
|
||||
logForDebugging(`[Sandbox Monitor] Log stream exited with code: ${code}`);
|
||||
});
|
||||
return () => {
|
||||
logForDebugging('[Sandbox Monitor] Stopping log monitor');
|
||||
logProcess.kill('SIGTERM');
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=macos-sandbox-utils.js.map
|
||||
180
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-config.js
vendored
Normal file
180
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-config.js
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Configuration for Sandbox Runtime
|
||||
* This is the main configuration interface that consumers pass to SandboxManager.initialize()
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Schema for domain patterns (e.g., "example.com", "*.npmjs.org")
|
||||
* Validates that domain patterns are safe and don't include overly broad wildcards
|
||||
*/
|
||||
const domainPatternSchema = z.string().refine(val => {
|
||||
// Reject protocols, paths, ports, etc.
|
||||
if (val.includes('://') || val.includes('/') || val.includes(':')) {
|
||||
return false;
|
||||
}
|
||||
// Allow localhost
|
||||
if (val === 'localhost')
|
||||
return true;
|
||||
// Allow wildcard domains like *.example.com
|
||||
if (val.startsWith('*.')) {
|
||||
const domain = val.slice(2);
|
||||
// After the *. there must be a valid domain with at least one more dot
|
||||
// e.g., *.example.com is valid, *.com is not (too broad)
|
||||
if (!domain.includes('.') ||
|
||||
domain.startsWith('.') ||
|
||||
domain.endsWith('.')) {
|
||||
return false;
|
||||
}
|
||||
// Count dots - must have at least 2 parts after the wildcard (e.g., example.com)
|
||||
const parts = domain.split('.');
|
||||
return parts.length >= 2 && parts.every(p => p.length > 0);
|
||||
}
|
||||
// Reject any other use of wildcards (e.g., *, *., etc.)
|
||||
if (val.includes('*')) {
|
||||
return false;
|
||||
}
|
||||
// Regular domains must have at least one dot and only valid characters
|
||||
return val.includes('.') && !val.startsWith('.') && !val.endsWith('.');
|
||||
}, {
|
||||
message: 'Invalid domain pattern. Must be a valid domain (e.g., "example.com") or wildcard (e.g., "*.example.com"). Overly broad patterns like "*.com" or "*" are not allowed for security reasons.',
|
||||
});
|
||||
/**
|
||||
* Schema for filesystem paths
|
||||
*/
|
||||
const filesystemPathSchema = z.string().min(1, 'Path cannot be empty');
|
||||
/**
|
||||
* Schema for MITM proxy configuration
|
||||
* Allows routing specific domains through an upstream MITM proxy via Unix socket
|
||||
*/
|
||||
const MitmProxyConfigSchema = z.object({
|
||||
socketPath: z.string().min(1).describe('Unix socket path to the MITM proxy'),
|
||||
domains: z
|
||||
.array(domainPatternSchema)
|
||||
.min(1)
|
||||
.describe('Domains to route through the MITM proxy (e.g., ["api.example.com", "*.internal.org"])'),
|
||||
});
|
||||
/**
|
||||
* Network configuration schema for validation
|
||||
*/
|
||||
export const NetworkConfigSchema = z.object({
|
||||
allowedDomains: z
|
||||
.array(domainPatternSchema)
|
||||
.describe('List of allowed domains (e.g., ["github.com", "*.npmjs.org"])'),
|
||||
deniedDomains: z
|
||||
.array(domainPatternSchema)
|
||||
.describe('List of denied domains'),
|
||||
allowUnixSockets: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('macOS only: Unix socket paths to allow. Ignored on Linux (seccomp cannot filter by path).'),
|
||||
allowAllUnixSockets: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('If true, allow all Unix sockets (disables blocking on both platforms).'),
|
||||
allowLocalBinding: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether to allow binding to local ports (default: false)'),
|
||||
httpProxyPort: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.optional()
|
||||
.describe('Port of an external HTTP proxy to use instead of starting a local one. When provided, the library will skip starting its own HTTP proxy and use this port. The external proxy must handle domain filtering.'),
|
||||
socksProxyPort: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.optional()
|
||||
.describe('Port of an external SOCKS proxy to use instead of starting a local one. When provided, the library will skip starting its own SOCKS proxy and use this port. The external proxy must handle domain filtering.'),
|
||||
mitmProxy: MitmProxyConfigSchema.optional().describe('Optional MITM proxy configuration. Routes matching domains through an upstream proxy via Unix socket while SRT still handles allow/deny filtering.'),
|
||||
});
|
||||
/**
|
||||
* Filesystem configuration schema for validation
|
||||
*/
|
||||
export const FilesystemConfigSchema = z.object({
|
||||
denyRead: z.array(filesystemPathSchema).describe('Paths denied for reading'),
|
||||
allowRead: z
|
||||
.array(filesystemPathSchema)
|
||||
.optional()
|
||||
.describe('Paths to re-allow reading within denied regions (takes precedence over denyRead). ' +
|
||||
'Use with denyRead to deny a broad region then allow back specific subdirectories.'),
|
||||
allowWrite: z
|
||||
.array(filesystemPathSchema)
|
||||
.describe('Paths allowed for writing'),
|
||||
denyWrite: z
|
||||
.array(filesystemPathSchema)
|
||||
.describe('Paths denied for writing (takes precedence over allowWrite)'),
|
||||
allowGitConfig: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Allow writes to .git/config files (default: false). Enables git remote URL updates while keeping .git/hooks protected.'),
|
||||
});
|
||||
/**
|
||||
* Configuration schema for ignoring specific sandbox violations
|
||||
* Maps command patterns to filesystem paths to ignore violations for.
|
||||
*/
|
||||
export const IgnoreViolationsConfigSchema = z
|
||||
.record(z.string(), z.array(z.string()))
|
||||
.describe('Map of command patterns to filesystem paths to ignore violations for. Use "*" to match all commands');
|
||||
/**
|
||||
* Ripgrep configuration schema
|
||||
*/
|
||||
export const RipgrepConfigSchema = z.object({
|
||||
command: z.string().describe('The ripgrep command to execute'),
|
||||
args: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Additional arguments to pass before ripgrep args'),
|
||||
argv0: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Override argv[0] when spawning (for multicall binaries that dispatch on argv[0])'),
|
||||
});
|
||||
/**
|
||||
* Seccomp configuration schema (Linux only)
|
||||
* Allows specifying custom paths to seccomp binaries
|
||||
*/
|
||||
export const SeccompConfigSchema = z.object({
|
||||
bpfPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Path to the unix-block.bpf filter file'),
|
||||
applyPath: z.string().optional().describe('Path to the apply-seccomp binary'),
|
||||
});
|
||||
/**
|
||||
* Main configuration schema for Sandbox Runtime validation
|
||||
*/
|
||||
export const SandboxRuntimeConfigSchema = z.object({
|
||||
network: NetworkConfigSchema.describe('Network restrictions configuration'),
|
||||
filesystem: FilesystemConfigSchema.describe('Filesystem restrictions configuration'),
|
||||
ignoreViolations: IgnoreViolationsConfigSchema.optional().describe('Optional configuration for ignoring specific violations'),
|
||||
enableWeakerNestedSandbox: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Enable weaker nested sandbox mode (for Docker environments)'),
|
||||
enableWeakerNetworkIsolation: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Enable weaker network isolation to allow access to com.apple.trustd.agent (macOS only). ' +
|
||||
'This is needed for Go programs (gh, gcloud, terraform, kubectl, etc.) to verify TLS certificates ' +
|
||||
'when using httpProxyPort with a MITM proxy and custom CA. Enabling this opens a potential data ' +
|
||||
'exfiltration vector through the trustd service. Only enable if you need Go TLS verification.'),
|
||||
ripgrep: RipgrepConfigSchema.optional().describe('Custom ripgrep configuration (default: { command: "rg" })'),
|
||||
mandatoryDenySearchDepth: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe('Maximum directory depth to search for dangerous files on Linux (default: 3). ' +
|
||||
'Higher values provide more protection but slower performance.'),
|
||||
allowPty: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Allow pseudo-terminal (pty) operations (macOS only)'),
|
||||
seccomp: SeccompConfigSchema.optional().describe('Custom seccomp binary paths (Linux only).'),
|
||||
});
|
||||
//# sourceMappingURL=sandbox-config.js.map
|
||||
786
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-manager.js
vendored
Normal file
786
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-manager.js
vendored
Normal file
@@ -0,0 +1,786 @@
|
||||
import { createHttpProxyServer } from './http-proxy.js';
|
||||
import { createSocksProxyServer } from './socks-proxy.js';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
import { whichSync } from '../utils/which.js';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { getPlatform, getWslVersion } from '../utils/platform.js';
|
||||
import * as fs from 'fs';
|
||||
import { wrapCommandWithSandboxLinux, initializeLinuxNetworkBridge, checkLinuxDependencies, cleanupBwrapMountPoints, } from './linux-sandbox-utils.js';
|
||||
import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, } from './macos-sandbox-utils.js';
|
||||
import { getDefaultWritePaths, containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, } from './sandbox-utils.js';
|
||||
import { SandboxViolationStore } from './sandbox-violation-store.js';
|
||||
import { EOL } from 'node:os';
|
||||
// ============================================================================
|
||||
// Private Module State
|
||||
// ============================================================================
|
||||
let config;
|
||||
let httpProxyServer;
|
||||
let socksProxyServer;
|
||||
let managerContext;
|
||||
let initializationPromise;
|
||||
let cleanupRegistered = false;
|
||||
let logMonitorShutdown;
|
||||
const sandboxViolationStore = new SandboxViolationStore();
|
||||
// ============================================================================
|
||||
// Private Helper Functions (not exported)
|
||||
// ============================================================================
|
||||
function registerCleanup() {
|
||||
if (cleanupRegistered) {
|
||||
return;
|
||||
}
|
||||
const cleanupHandler = () => reset().catch(e => {
|
||||
logForDebugging(`Cleanup failed in registerCleanup ${e}`, {
|
||||
level: 'error',
|
||||
});
|
||||
});
|
||||
process.once('exit', cleanupHandler);
|
||||
process.once('SIGINT', cleanupHandler);
|
||||
process.once('SIGTERM', cleanupHandler);
|
||||
cleanupRegistered = true;
|
||||
}
|
||||
function matchesDomainPattern(hostname, pattern) {
|
||||
// Support wildcard patterns like *.example.com
|
||||
// This matches any subdomain but not the base domain itself
|
||||
if (pattern.startsWith('*.')) {
|
||||
const baseDomain = pattern.substring(2); // Remove '*.'
|
||||
return hostname.toLowerCase().endsWith('.' + baseDomain.toLowerCase());
|
||||
}
|
||||
// Exact match for non-wildcard patterns
|
||||
return hostname.toLowerCase() === pattern.toLowerCase();
|
||||
}
|
||||
async function filterNetworkRequest(port, host, sandboxAskCallback) {
|
||||
if (!config) {
|
||||
logForDebugging('No config available, denying network request');
|
||||
return false;
|
||||
}
|
||||
// Check denied domains first
|
||||
for (const deniedDomain of config.network.deniedDomains) {
|
||||
if (matchesDomainPattern(host, deniedDomain)) {
|
||||
logForDebugging(`Denied by config rule: ${host}:${port}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Check allowed domains
|
||||
for (const allowedDomain of config.network.allowedDomains) {
|
||||
if (matchesDomainPattern(host, allowedDomain)) {
|
||||
logForDebugging(`Allowed by config rule: ${host}:${port}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// No matching rules - ask user or deny
|
||||
if (!sandboxAskCallback) {
|
||||
logForDebugging(`No matching config rule, denying: ${host}:${port}`);
|
||||
return false;
|
||||
}
|
||||
logForDebugging(`No matching config rule, asking user: ${host}:${port}`);
|
||||
try {
|
||||
const userAllowed = await sandboxAskCallback({ host, port });
|
||||
if (userAllowed) {
|
||||
logForDebugging(`User allowed: ${host}:${port}`);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
logForDebugging(`User denied: ${host}:${port}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logForDebugging(`Error in permission callback: ${error}`, {
|
||||
level: 'error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get the MITM proxy socket path for a given host, if configured.
|
||||
* Returns the socket path if the host matches any MITM domain pattern,
|
||||
* otherwise returns undefined.
|
||||
*/
|
||||
function getMitmSocketPath(host) {
|
||||
if (!config?.network.mitmProxy) {
|
||||
return undefined;
|
||||
}
|
||||
const { socketPath, domains } = config.network.mitmProxy;
|
||||
for (const pattern of domains) {
|
||||
if (matchesDomainPattern(host, pattern)) {
|
||||
logForDebugging(`Host ${host} matches MITM pattern ${pattern}`);
|
||||
return socketPath;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
async function startHttpProxyServer(sandboxAskCallback) {
|
||||
httpProxyServer = createHttpProxyServer({
|
||||
filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
|
||||
getMitmSocketPath,
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!httpProxyServer) {
|
||||
reject(new Error('HTTP proxy server undefined before listen'));
|
||||
return;
|
||||
}
|
||||
const server = httpProxyServer;
|
||||
server.once('error', reject);
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === 'object') {
|
||||
server.unref();
|
||||
logForDebugging(`HTTP proxy listening on localhost:${address.port}`);
|
||||
resolve(address.port);
|
||||
}
|
||||
else {
|
||||
reject(new Error('Failed to get proxy server address'));
|
||||
}
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
async function startSocksProxyServer(sandboxAskCallback) {
|
||||
socksProxyServer = createSocksProxyServer({
|
||||
filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!socksProxyServer) {
|
||||
// This is mostly just for the typechecker
|
||||
reject(new Error('SOCKS proxy server undefined before listen'));
|
||||
return;
|
||||
}
|
||||
socksProxyServer
|
||||
.listen(0, '127.0.0.1')
|
||||
.then((port) => {
|
||||
socksProxyServer?.unref();
|
||||
resolve(port);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
// ============================================================================
|
||||
// Public Module Functions (will be exported via namespace)
|
||||
// ============================================================================
|
||||
async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor = false) {
|
||||
// Return if already initializing
|
||||
if (initializationPromise) {
|
||||
await initializationPromise;
|
||||
return;
|
||||
}
|
||||
// Store config for use by other functions
|
||||
config = runtimeConfig;
|
||||
// Check dependencies
|
||||
const deps = checkDependencies();
|
||||
if (deps.errors.length > 0) {
|
||||
throw new Error(`Sandbox dependencies not available: ${deps.errors.join(', ')}`);
|
||||
}
|
||||
// Start log monitor for macOS if enabled
|
||||
if (enableLogMonitor && getPlatform() === 'macos') {
|
||||
logMonitorShutdown = startMacOSSandboxLogMonitor(sandboxViolationStore.addViolation.bind(sandboxViolationStore), config.ignoreViolations);
|
||||
logForDebugging('Started macOS sandbox log monitor');
|
||||
}
|
||||
// Register cleanup handlers first time
|
||||
registerCleanup();
|
||||
// Initialize network infrastructure
|
||||
initializationPromise = (async () => {
|
||||
try {
|
||||
// Conditionally start proxy servers based on config
|
||||
let httpProxyPort;
|
||||
if (config.network.httpProxyPort !== undefined) {
|
||||
// Use external HTTP proxy (don't start a server)
|
||||
httpProxyPort = config.network.httpProxyPort;
|
||||
logForDebugging(`Using external HTTP proxy on port ${httpProxyPort}`);
|
||||
}
|
||||
else {
|
||||
// Start local HTTP proxy
|
||||
httpProxyPort = await startHttpProxyServer(sandboxAskCallback);
|
||||
}
|
||||
let socksProxyPort;
|
||||
if (config.network.socksProxyPort !== undefined) {
|
||||
// Use external SOCKS proxy (don't start a server)
|
||||
socksProxyPort = config.network.socksProxyPort;
|
||||
logForDebugging(`Using external SOCKS proxy on port ${socksProxyPort}`);
|
||||
}
|
||||
else {
|
||||
// Start local SOCKS proxy
|
||||
socksProxyPort = await startSocksProxyServer(sandboxAskCallback);
|
||||
}
|
||||
// Initialize platform-specific infrastructure
|
||||
let linuxBridge;
|
||||
if (getPlatform() === 'linux') {
|
||||
linuxBridge = await initializeLinuxNetworkBridge(httpProxyPort, socksProxyPort);
|
||||
}
|
||||
const context = {
|
||||
httpProxyPort,
|
||||
socksProxyPort,
|
||||
linuxBridge,
|
||||
};
|
||||
managerContext = context;
|
||||
logForDebugging('Network infrastructure initialized');
|
||||
return context;
|
||||
}
|
||||
catch (error) {
|
||||
// Clear state on error so initialization can be retried
|
||||
initializationPromise = undefined;
|
||||
managerContext = undefined;
|
||||
reset().catch(e => {
|
||||
logForDebugging(`Cleanup failed in initializationPromise ${e}`, {
|
||||
level: 'error',
|
||||
});
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
await initializationPromise;
|
||||
}
|
||||
function isSupportedPlatform() {
|
||||
const platform = getPlatform();
|
||||
if (platform === 'linux') {
|
||||
// WSL1 doesn't support bubblewrap
|
||||
return getWslVersion() !== '1';
|
||||
}
|
||||
return platform === 'macos';
|
||||
}
|
||||
function isSandboxingEnabled() {
|
||||
// Sandboxing is enabled if config has been set (via initialize())
|
||||
return config !== undefined;
|
||||
}
|
||||
/**
|
||||
* Check sandbox dependencies for the current platform
|
||||
* @param ripgrepConfig - Ripgrep command to check. If not provided, uses config from initialization or defaults to 'rg'
|
||||
* @returns { warnings, errors } - errors mean sandbox cannot run, warnings mean degraded functionality
|
||||
*/
|
||||
function checkDependencies(ripgrepConfig) {
|
||||
if (!isSupportedPlatform()) {
|
||||
return { errors: ['Unsupported platform'], warnings: [] };
|
||||
}
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
// Check ripgrep - use provided config, then initialized config, then default 'rg'
|
||||
const rgToCheck = ripgrepConfig ?? config?.ripgrep ?? { command: 'rg' };
|
||||
if (whichSync(rgToCheck.command) === null) {
|
||||
errors.push(`ripgrep (${rgToCheck.command}) not found`);
|
||||
}
|
||||
const platform = getPlatform();
|
||||
if (platform === 'linux') {
|
||||
const linuxDeps = checkLinuxDependencies(config?.seccomp);
|
||||
errors.push(...linuxDeps.errors);
|
||||
warnings.push(...linuxDeps.warnings);
|
||||
}
|
||||
return { errors, warnings };
|
||||
}
|
||||
function getFsReadConfig() {
|
||||
if (!config) {
|
||||
return { denyOnly: [], allowWithinDeny: [] };
|
||||
}
|
||||
const denyPaths = [];
|
||||
for (const p of config.filesystem.denyRead) {
|
||||
const stripped = removeTrailingGlobSuffix(p);
|
||||
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
||||
// Expand glob to concrete paths on Linux (bubblewrap doesn't support globs)
|
||||
const expanded = expandGlobPattern(p);
|
||||
logForDebugging(`[Sandbox] Expanded glob pattern "${p}" to ${expanded.length} paths on Linux`);
|
||||
denyPaths.push(...expanded);
|
||||
}
|
||||
else {
|
||||
denyPaths.push(stripped);
|
||||
}
|
||||
}
|
||||
// Process allowRead paths (re-allow within denied regions)
|
||||
const allowPaths = [];
|
||||
for (const p of config.filesystem.allowRead ?? []) {
|
||||
const stripped = removeTrailingGlobSuffix(p);
|
||||
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
||||
const expanded = expandGlobPattern(p);
|
||||
logForDebugging(`[Sandbox] Expanded allowRead glob pattern "${p}" to ${expanded.length} paths on Linux`);
|
||||
allowPaths.push(...expanded);
|
||||
}
|
||||
else {
|
||||
allowPaths.push(stripped);
|
||||
}
|
||||
}
|
||||
return {
|
||||
denyOnly: denyPaths,
|
||||
allowWithinDeny: allowPaths,
|
||||
};
|
||||
}
|
||||
function getFsWriteConfig() {
|
||||
if (!config) {
|
||||
return { allowOnly: getDefaultWritePaths(), denyWithinAllow: [] };
|
||||
}
|
||||
// Filter out glob patterns on Linux/WSL for allowWrite (bubblewrap doesn't support globs)
|
||||
const allowPaths = config.filesystem.allowWrite
|
||||
.map(path => removeTrailingGlobSuffix(path))
|
||||
.filter(path => {
|
||||
if (getPlatform() === 'linux' && containsGlobChars(path)) {
|
||||
logForDebugging(`Skipping glob pattern on Linux/WSL: ${path}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Filter out glob patterns on Linux/WSL for denyWrite (bubblewrap doesn't support globs)
|
||||
const denyPaths = config.filesystem.denyWrite
|
||||
.map(path => removeTrailingGlobSuffix(path))
|
||||
.filter(path => {
|
||||
if (getPlatform() === 'linux' && containsGlobChars(path)) {
|
||||
logForDebugging(`Skipping glob pattern on Linux/WSL: ${path}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Build allowOnly list: default paths + configured allow paths
|
||||
const allowOnly = [...getDefaultWritePaths(), ...allowPaths];
|
||||
return {
|
||||
allowOnly,
|
||||
denyWithinAllow: denyPaths,
|
||||
};
|
||||
}
|
||||
function getNetworkRestrictionConfig() {
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
const allowedHosts = config.network.allowedDomains;
|
||||
const deniedHosts = config.network.deniedDomains;
|
||||
return {
|
||||
...(allowedHosts.length > 0 && { allowedHosts }),
|
||||
...(deniedHosts.length > 0 && { deniedHosts }),
|
||||
};
|
||||
}
|
||||
function getAllowUnixSockets() {
|
||||
return config?.network?.allowUnixSockets;
|
||||
}
|
||||
function getAllowAllUnixSockets() {
|
||||
return config?.network?.allowAllUnixSockets;
|
||||
}
|
||||
function getAllowLocalBinding() {
|
||||
return config?.network?.allowLocalBinding;
|
||||
}
|
||||
function getIgnoreViolations() {
|
||||
return config?.ignoreViolations;
|
||||
}
|
||||
function getEnableWeakerNestedSandbox() {
|
||||
return config?.enableWeakerNestedSandbox;
|
||||
}
|
||||
function getEnableWeakerNetworkIsolation() {
|
||||
return config?.enableWeakerNetworkIsolation;
|
||||
}
|
||||
function getRipgrepConfig() {
|
||||
return config?.ripgrep ?? { command: 'rg' };
|
||||
}
|
||||
function getMandatoryDenySearchDepth() {
|
||||
return config?.mandatoryDenySearchDepth ?? 3;
|
||||
}
|
||||
function getAllowGitConfig() {
|
||||
return config?.filesystem?.allowGitConfig ?? false;
|
||||
}
|
||||
function getSeccompConfig() {
|
||||
return config?.seccomp;
|
||||
}
|
||||
function getProxyPort() {
|
||||
return managerContext?.httpProxyPort;
|
||||
}
|
||||
function getSocksProxyPort() {
|
||||
return managerContext?.socksProxyPort;
|
||||
}
|
||||
function getLinuxHttpSocketPath() {
|
||||
return managerContext?.linuxBridge?.httpSocketPath;
|
||||
}
|
||||
function getLinuxSocksSocketPath() {
|
||||
return managerContext?.linuxBridge?.socksSocketPath;
|
||||
}
|
||||
/**
|
||||
* Wait for network initialization to complete if already in progress
|
||||
* Returns true if initialized successfully, false otherwise
|
||||
*/
|
||||
async function waitForNetworkInitialization() {
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
if (initializationPromise) {
|
||||
try {
|
||||
await initializationPromise;
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return managerContext !== undefined;
|
||||
}
|
||||
async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
||||
const platform = getPlatform();
|
||||
// Get configs - use custom if provided, otherwise fall back to main config
|
||||
// If neither exists, defaults to empty arrays (most restrictive)
|
||||
// Always include default system write paths (like /dev/null, /tmp/claude)
|
||||
//
|
||||
// Strip trailing /** and filter remaining globs on Linux (bwrap needs
|
||||
// real paths, not globs; macOS subpath matching is also recursive so
|
||||
// stripping is harmless there).
|
||||
const stripWriteGlobs = (paths) => paths
|
||||
.map(p => removeTrailingGlobSuffix(p))
|
||||
.filter(p => {
|
||||
if (getPlatform() === 'linux' && containsGlobChars(p)) {
|
||||
logForDebugging(`[Sandbox] Skipping glob write pattern on Linux: ${p}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const userAllowWrite = stripWriteGlobs(customConfig?.filesystem?.allowWrite ?? config?.filesystem.allowWrite ?? []);
|
||||
const writeConfig = {
|
||||
allowOnly: [...getDefaultWritePaths(), ...userAllowWrite],
|
||||
denyWithinAllow: stripWriteGlobs(customConfig?.filesystem?.denyWrite ?? config?.filesystem.denyWrite ?? []),
|
||||
};
|
||||
const rawDenyRead = customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [];
|
||||
const expandedDenyRead = [];
|
||||
for (const p of rawDenyRead) {
|
||||
const stripped = removeTrailingGlobSuffix(p);
|
||||
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
||||
expandedDenyRead.push(...expandGlobPattern(p));
|
||||
}
|
||||
else {
|
||||
expandedDenyRead.push(stripped);
|
||||
}
|
||||
}
|
||||
const rawAllowRead = customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [];
|
||||
const expandedAllowRead = [];
|
||||
for (const p of rawAllowRead) {
|
||||
const stripped = removeTrailingGlobSuffix(p);
|
||||
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
||||
expandedAllowRead.push(...expandGlobPattern(p));
|
||||
}
|
||||
else {
|
||||
expandedAllowRead.push(stripped);
|
||||
}
|
||||
}
|
||||
const readConfig = {
|
||||
denyOnly: expandedDenyRead,
|
||||
allowWithinDeny: expandedAllowRead,
|
||||
};
|
||||
// Check if network config is specified - this determines if we need network restrictions
|
||||
// Network restriction is needed when:
|
||||
// 1. customConfig has network.allowedDomains defined (even if empty array = block all)
|
||||
// 2. OR config has network.allowedDomains defined (even if empty array = block all)
|
||||
// An empty allowedDomains array means "no domains allowed" = block all network access
|
||||
const hasNetworkConfig = customConfig?.network?.allowedDomains !== undefined ||
|
||||
config?.network?.allowedDomains !== undefined;
|
||||
// Network RESTRICTION is needed whenever network config is specified
|
||||
// This includes empty allowedDomains which means "block all network"
|
||||
const needsNetworkRestriction = hasNetworkConfig;
|
||||
// Network PROXY is needed whenever network config is specified
|
||||
// Even with empty allowedDomains, we route through proxy so that:
|
||||
// 1. updateConfig() can enable network access for already-running processes
|
||||
// 2. The proxy blocks all requests when allowlist is empty
|
||||
const needsNetworkProxy = hasNetworkConfig;
|
||||
// Wait for network initialization only if proxy is actually needed
|
||||
if (needsNetworkProxy) {
|
||||
await waitForNetworkInitialization();
|
||||
}
|
||||
// Check custom config to allow pseudo-terminal (can be applied dynamically)
|
||||
const allowPty = customConfig?.allowPty ?? config?.allowPty;
|
||||
switch (platform) {
|
||||
case 'macos':
|
||||
// macOS sandbox profile supports glob patterns directly, no ripgrep needed
|
||||
return wrapCommandWithSandboxMacOS({
|
||||
command,
|
||||
needsNetworkRestriction,
|
||||
// Only pass proxy ports if proxy is running (when there are domains to filter)
|
||||
httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined,
|
||||
socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined,
|
||||
readConfig,
|
||||
writeConfig,
|
||||
allowUnixSockets: getAllowUnixSockets(),
|
||||
allowAllUnixSockets: getAllowAllUnixSockets(),
|
||||
allowLocalBinding: getAllowLocalBinding(),
|
||||
ignoreViolations: getIgnoreViolations(),
|
||||
allowPty,
|
||||
allowGitConfig: getAllowGitConfig(),
|
||||
enableWeakerNetworkIsolation: getEnableWeakerNetworkIsolation(),
|
||||
binShell,
|
||||
});
|
||||
case 'linux':
|
||||
return wrapCommandWithSandboxLinux({
|
||||
command,
|
||||
needsNetworkRestriction,
|
||||
// Only pass socket paths if proxy is running (when there are domains to filter)
|
||||
httpSocketPath: needsNetworkProxy
|
||||
? getLinuxHttpSocketPath()
|
||||
: undefined,
|
||||
socksSocketPath: needsNetworkProxy
|
||||
? getLinuxSocksSocketPath()
|
||||
: undefined,
|
||||
httpProxyPort: needsNetworkProxy
|
||||
? managerContext?.httpProxyPort
|
||||
: undefined,
|
||||
socksProxyPort: needsNetworkProxy
|
||||
? managerContext?.socksProxyPort
|
||||
: undefined,
|
||||
readConfig,
|
||||
writeConfig,
|
||||
enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(),
|
||||
allowAllUnixSockets: getAllowAllUnixSockets(),
|
||||
binShell,
|
||||
ripgrepConfig: getRipgrepConfig(),
|
||||
mandatoryDenySearchDepth: getMandatoryDenySearchDepth(),
|
||||
allowGitConfig: getAllowGitConfig(),
|
||||
seccompConfig: getSeccompConfig(),
|
||||
abortSignal,
|
||||
});
|
||||
default:
|
||||
// Unsupported platform - this should not happen since isSandboxingEnabled() checks platform support
|
||||
throw new Error(`Sandbox configuration is not supported on platform: ${platform}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get the current sandbox configuration
|
||||
* @returns The current configuration, or undefined if not initialized
|
||||
*/
|
||||
function getConfig() {
|
||||
return config;
|
||||
}
|
||||
/**
|
||||
* Update the sandbox configuration
|
||||
* @param newConfig - The new configuration to use
|
||||
*/
|
||||
function updateConfig(newConfig) {
|
||||
// Deep clone the config to avoid mutations
|
||||
config = cloneDeep(newConfig);
|
||||
logForDebugging('Sandbox configuration updated');
|
||||
}
|
||||
/**
|
||||
* Lightweight cleanup to call after each sandboxed command completes.
|
||||
*
|
||||
* On Linux, bwrap creates empty files on the host filesystem as mount points
|
||||
* when protecting non-existent deny paths (e.g. ~/.bashrc, ~/.gitconfig).
|
||||
* These persist after bwrap exits. This function removes them.
|
||||
*
|
||||
* Safe to call on any platform — it's a no-op on macOS.
|
||||
* Also called automatically by reset() and on process exit as safety nets.
|
||||
*/
|
||||
function cleanupAfterCommand() {
|
||||
cleanupBwrapMountPoints();
|
||||
}
|
||||
async function reset() {
|
||||
// Clean up any leftover bwrap mount points
|
||||
cleanupAfterCommand();
|
||||
// Stop log monitor
|
||||
if (logMonitorShutdown) {
|
||||
logMonitorShutdown();
|
||||
logMonitorShutdown = undefined;
|
||||
}
|
||||
if (managerContext?.linuxBridge) {
|
||||
const { httpSocketPath, socksSocketPath, httpBridgeProcess, socksBridgeProcess, } = managerContext.linuxBridge;
|
||||
// Create array to wait for process exits
|
||||
const exitPromises = [];
|
||||
// Kill HTTP bridge and wait for it to exit
|
||||
if (httpBridgeProcess.pid && !httpBridgeProcess.killed) {
|
||||
try {
|
||||
process.kill(httpBridgeProcess.pid, 'SIGTERM');
|
||||
logForDebugging('Sent SIGTERM to HTTP bridge process');
|
||||
// Wait for process to exit
|
||||
exitPromises.push(new Promise(resolve => {
|
||||
httpBridgeProcess.once('exit', () => {
|
||||
logForDebugging('HTTP bridge process exited');
|
||||
resolve();
|
||||
});
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (!httpBridgeProcess.killed) {
|
||||
logForDebugging('HTTP bridge did not exit, forcing SIGKILL', {
|
||||
level: 'warn',
|
||||
});
|
||||
try {
|
||||
if (httpBridgeProcess.pid) {
|
||||
process.kill(httpBridgeProcess.pid, 'SIGKILL');
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
if (err.code !== 'ESRCH') {
|
||||
logForDebugging(`Error killing HTTP bridge: ${err}`, {
|
||||
level: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Kill SOCKS bridge and wait for it to exit
|
||||
if (socksBridgeProcess.pid && !socksBridgeProcess.killed) {
|
||||
try {
|
||||
process.kill(socksBridgeProcess.pid, 'SIGTERM');
|
||||
logForDebugging('Sent SIGTERM to SOCKS bridge process');
|
||||
// Wait for process to exit
|
||||
exitPromises.push(new Promise(resolve => {
|
||||
socksBridgeProcess.once('exit', () => {
|
||||
logForDebugging('SOCKS bridge process exited');
|
||||
resolve();
|
||||
});
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (!socksBridgeProcess.killed) {
|
||||
logForDebugging('SOCKS bridge did not exit, forcing SIGKILL', {
|
||||
level: 'warn',
|
||||
});
|
||||
try {
|
||||
if (socksBridgeProcess.pid) {
|
||||
process.kill(socksBridgeProcess.pid, 'SIGKILL');
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
if (err.code !== 'ESRCH') {
|
||||
logForDebugging(`Error killing SOCKS bridge: ${err}`, {
|
||||
level: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Wait for both processes to exit
|
||||
await Promise.all(exitPromises);
|
||||
// Clean up sockets
|
||||
if (httpSocketPath) {
|
||||
try {
|
||||
fs.rmSync(httpSocketPath, { force: true });
|
||||
logForDebugging('Cleaned up HTTP socket');
|
||||
}
|
||||
catch (err) {
|
||||
logForDebugging(`HTTP socket cleanup error: ${err}`, {
|
||||
level: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (socksSocketPath) {
|
||||
try {
|
||||
fs.rmSync(socksSocketPath, { force: true });
|
||||
logForDebugging('Cleaned up SOCKS socket');
|
||||
}
|
||||
catch (err) {
|
||||
logForDebugging(`SOCKS socket cleanup error: ${err}`, {
|
||||
level: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Close servers in parallel (only if they exist, i.e., were started by us)
|
||||
const closePromises = [];
|
||||
if (httpProxyServer) {
|
||||
const server = httpProxyServer; // Capture reference to avoid TypeScript error
|
||||
const httpClose = new Promise(resolve => {
|
||||
server.close(error => {
|
||||
if (error && error.message !== 'Server is not running.') {
|
||||
logForDebugging(`Error closing HTTP proxy server: ${error.message}`, {
|
||||
level: 'error',
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
closePromises.push(httpClose);
|
||||
}
|
||||
if (socksProxyServer) {
|
||||
const socksClose = socksProxyServer.close().catch((error) => {
|
||||
logForDebugging(`Error closing SOCKS proxy server: ${error.message}`, {
|
||||
level: 'error',
|
||||
});
|
||||
});
|
||||
closePromises.push(socksClose);
|
||||
}
|
||||
// Wait for all servers to close
|
||||
await Promise.all(closePromises);
|
||||
// Clear references
|
||||
httpProxyServer = undefined;
|
||||
socksProxyServer = undefined;
|
||||
managerContext = undefined;
|
||||
initializationPromise = undefined;
|
||||
}
|
||||
function getSandboxViolationStore() {
|
||||
return sandboxViolationStore;
|
||||
}
|
||||
function annotateStderrWithSandboxFailures(command, stderr) {
|
||||
if (!config) {
|
||||
return stderr;
|
||||
}
|
||||
const violations = sandboxViolationStore.getViolationsForCommand(command);
|
||||
if (violations.length === 0) {
|
||||
return stderr;
|
||||
}
|
||||
let annotated = stderr;
|
||||
annotated += EOL + '<sandbox_violations>' + EOL;
|
||||
for (const violation of violations) {
|
||||
annotated += violation.line + EOL;
|
||||
}
|
||||
annotated += '</sandbox_violations>';
|
||||
return annotated;
|
||||
}
|
||||
/**
|
||||
* Returns glob patterns from Edit/Read permission rules that are not
|
||||
* fully supported on Linux. Returns empty array on macOS or when
|
||||
* sandboxing is disabled.
|
||||
*
|
||||
* Patterns ending with /** are excluded since they work as subpaths.
|
||||
*/
|
||||
function getLinuxGlobPatternWarnings() {
|
||||
// Only warn on Linux/WSL (bubblewrap doesn't support globs)
|
||||
// macOS supports glob patterns via regex conversion
|
||||
if (getPlatform() !== 'linux' || !config) {
|
||||
return [];
|
||||
}
|
||||
const globPatterns = [];
|
||||
// Check filesystem paths for glob patterns
|
||||
// Note: denyRead is excluded because globs are now expanded to concrete paths on Linux
|
||||
const allPaths = [
|
||||
...config.filesystem.allowWrite,
|
||||
...config.filesystem.denyWrite,
|
||||
];
|
||||
for (const path of allPaths) {
|
||||
// Strip trailing /** since that's just a subpath (directory and everything under it)
|
||||
const pathWithoutTrailingStar = removeTrailingGlobSuffix(path);
|
||||
// Only warn if there are still glob characters after removing trailing /**
|
||||
if (containsGlobChars(pathWithoutTrailingStar)) {
|
||||
globPatterns.push(path);
|
||||
}
|
||||
}
|
||||
return globPatterns;
|
||||
}
|
||||
// ============================================================================
|
||||
// Export as Namespace with Interface
|
||||
// ============================================================================
|
||||
/**
|
||||
* Global sandbox manager that handles both network and filesystem restrictions
|
||||
* for this session. This runs outside of the sandbox, on the host machine.
|
||||
*/
|
||||
export const SandboxManager = {
|
||||
initialize,
|
||||
isSupportedPlatform,
|
||||
isSandboxingEnabled,
|
||||
checkDependencies,
|
||||
getFsReadConfig,
|
||||
getFsWriteConfig,
|
||||
getNetworkRestrictionConfig,
|
||||
getAllowUnixSockets,
|
||||
getAllowLocalBinding,
|
||||
getIgnoreViolations,
|
||||
getEnableWeakerNestedSandbox,
|
||||
getProxyPort,
|
||||
getSocksProxyPort,
|
||||
getLinuxHttpSocketPath,
|
||||
getLinuxSocksSocketPath,
|
||||
waitForNetworkInitialization,
|
||||
wrapWithSandbox,
|
||||
cleanupAfterCommand,
|
||||
reset,
|
||||
getSandboxViolationStore,
|
||||
annotateStderrWithSandboxFailures,
|
||||
getLinuxGlobPatternWarnings,
|
||||
getConfig,
|
||||
updateConfig,
|
||||
};
|
||||
//# sourceMappingURL=sandbox-manager.js.map
|
||||
435
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js
vendored
Normal file
435
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js
vendored
Normal file
@@ -0,0 +1,435 @@
|
||||
import { homedir } from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { getPlatform } from '../utils/platform.js';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
/**
|
||||
* Dangerous files that should be protected from writes.
|
||||
* These files can be used for code execution or data exfiltration.
|
||||
*/
|
||||
export const DANGEROUS_FILES = [
|
||||
'.gitconfig',
|
||||
'.gitmodules',
|
||||
'.bashrc',
|
||||
'.bash_profile',
|
||||
'.zshrc',
|
||||
'.zprofile',
|
||||
'.profile',
|
||||
'.ripgreprc',
|
||||
'.mcp.json',
|
||||
];
|
||||
/**
|
||||
* Dangerous directories that should be protected from writes.
|
||||
* These directories contain sensitive configuration or executable files.
|
||||
*/
|
||||
export const DANGEROUS_DIRECTORIES = ['.git', '.vscode', '.idea'];
|
||||
/**
|
||||
* Get the list of dangerous directories to deny writes to.
|
||||
* Excludes .git since we need it writable for git operations -
|
||||
* instead we block specific paths within .git (hooks and config).
|
||||
*/
|
||||
export function getDangerousDirectories() {
|
||||
return [
|
||||
...DANGEROUS_DIRECTORIES.filter(d => d !== '.git'),
|
||||
'.claude/commands',
|
||||
'.claude/agents',
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Normalizes a path for case-insensitive comparison.
|
||||
* This prevents bypassing security checks using mixed-case paths on case-insensitive
|
||||
* filesystems (macOS/Windows) like `.cLauDe/Settings.locaL.json`.
|
||||
*
|
||||
* We always normalize to lowercase regardless of platform for consistent security.
|
||||
* @param path The path to normalize
|
||||
* @returns The lowercase path for safe comparison
|
||||
*/
|
||||
export function normalizeCaseForComparison(pathStr) {
|
||||
return pathStr.toLowerCase();
|
||||
}
|
||||
/**
|
||||
* Check if a path pattern contains glob characters
|
||||
*/
|
||||
export function containsGlobChars(pathPattern) {
|
||||
return (pathPattern.includes('*') ||
|
||||
pathPattern.includes('?') ||
|
||||
pathPattern.includes('[') ||
|
||||
pathPattern.includes(']'));
|
||||
}
|
||||
/**
|
||||
* Remove trailing /** glob suffix from a path pattern
|
||||
* Used to normalize path patterns since /** just means "directory and everything under it"
|
||||
*/
|
||||
export function removeTrailingGlobSuffix(pathPattern) {
|
||||
const stripped = pathPattern.replace(/\/\*\*$/, '');
|
||||
return stripped || '/';
|
||||
}
|
||||
/**
|
||||
* Check if a symlink resolution crosses expected path boundaries.
|
||||
*
|
||||
* When resolving symlinks for sandbox path normalization, we need to ensure
|
||||
* the resolved path doesn't unexpectedly broaden the scope. This function
|
||||
* returns true if the resolved path is an ancestor of the original path
|
||||
* or resolves to a system root, which would indicate the symlink points
|
||||
* outside expected boundaries.
|
||||
*
|
||||
* @param originalPath - The original path before symlink resolution
|
||||
* @param resolvedPath - The path after fs.realpathSync() resolution
|
||||
* @returns true if the resolved path is outside expected boundaries
|
||||
*/
|
||||
export function isSymlinkOutsideBoundary(originalPath, resolvedPath) {
|
||||
const normalizedOriginal = path.normalize(originalPath);
|
||||
const normalizedResolved = path.normalize(resolvedPath);
|
||||
// Same path after normalization - OK
|
||||
if (normalizedResolved === normalizedOriginal) {
|
||||
return false;
|
||||
}
|
||||
// Handle macOS /tmp -> /private/tmp canonical resolution
|
||||
// This is a legitimate system symlink that should be allowed
|
||||
// /tmp/claude -> /private/tmp/claude is OK
|
||||
// /var/folders/... -> /private/var/folders/... is OK
|
||||
if (normalizedOriginal.startsWith('/tmp/') &&
|
||||
normalizedResolved === '/private' + normalizedOriginal) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedOriginal.startsWith('/var/') &&
|
||||
normalizedResolved === '/private' + normalizedOriginal) {
|
||||
return false;
|
||||
}
|
||||
// Also handle the reverse: /private/tmp/... resolving to itself
|
||||
if (normalizedOriginal.startsWith('/private/tmp/') &&
|
||||
normalizedResolved === normalizedOriginal) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedOriginal.startsWith('/private/var/') &&
|
||||
normalizedResolved === normalizedOriginal) {
|
||||
return false;
|
||||
}
|
||||
// If resolved path is "/" it's outside expected boundaries
|
||||
if (normalizedResolved === '/') {
|
||||
return true;
|
||||
}
|
||||
// If resolved path is very short (single component like /tmp, /usr, /var),
|
||||
// it's likely outside expected boundaries
|
||||
const resolvedParts = normalizedResolved.split('/').filter(Boolean);
|
||||
if (resolvedParts.length <= 1) {
|
||||
return true;
|
||||
}
|
||||
// If original path starts with resolved path, the resolved path is an ancestor
|
||||
// e.g., /tmp/claude -> /tmp means the symlink points to a broader scope
|
||||
if (normalizedOriginal.startsWith(normalizedResolved + '/')) {
|
||||
return true;
|
||||
}
|
||||
// Also check the canonical form of the original path for macOS
|
||||
// e.g., /tmp/claude should also be checked as /private/tmp/claude
|
||||
let canonicalOriginal = normalizedOriginal;
|
||||
if (normalizedOriginal.startsWith('/tmp/')) {
|
||||
canonicalOriginal = '/private' + normalizedOriginal;
|
||||
}
|
||||
else if (normalizedOriginal.startsWith('/var/')) {
|
||||
canonicalOriginal = '/private' + normalizedOriginal;
|
||||
}
|
||||
if (canonicalOriginal !== normalizedOriginal &&
|
||||
canonicalOriginal.startsWith(normalizedResolved + '/')) {
|
||||
return true;
|
||||
}
|
||||
// STRICT CHECK: Only allow resolutions that stay within the expected path tree
|
||||
// The resolved path must either:
|
||||
// 1. Start with the original path (deeper/same) - already covered by returning false below
|
||||
// 2. Start with the canonical original (deeper/same under canonical form)
|
||||
// 3. BE the canonical form of the original (e.g., /tmp/x -> /private/tmp/x)
|
||||
// Any other resolution (e.g., /tmp/claude -> /Users/dworken) is outside expected bounds
|
||||
const resolvedStartsWithOriginal = normalizedResolved.startsWith(normalizedOriginal + '/');
|
||||
const resolvedStartsWithCanonical = canonicalOriginal !== normalizedOriginal &&
|
||||
normalizedResolved.startsWith(canonicalOriginal + '/');
|
||||
const resolvedIsCanonical = canonicalOriginal !== normalizedOriginal &&
|
||||
normalizedResolved === canonicalOriginal;
|
||||
const resolvedIsSame = normalizedResolved === normalizedOriginal;
|
||||
// If resolved path is not within expected tree, it's outside boundary
|
||||
if (!resolvedIsSame &&
|
||||
!resolvedIsCanonical &&
|
||||
!resolvedStartsWithOriginal &&
|
||||
!resolvedStartsWithCanonical) {
|
||||
return true;
|
||||
}
|
||||
// Allow resolution to same directory level or deeper within expected tree
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Normalize a path for use in sandbox configurations
|
||||
* Handles:
|
||||
* - Tilde (~) expansion for home directory
|
||||
* - Relative paths (./foo, ../foo, etc.) converted to absolute
|
||||
* - Absolute paths remain unchanged
|
||||
* - Symlinks are resolved to their real paths for non-glob patterns
|
||||
* - Glob patterns preserve wildcards after path normalization
|
||||
*
|
||||
* Returns the absolute path with symlinks resolved (or normalized glob pattern)
|
||||
*/
|
||||
export function normalizePathForSandbox(pathPattern) {
|
||||
const cwd = process.cwd();
|
||||
let normalizedPath = pathPattern;
|
||||
// Expand ~ to home directory
|
||||
if (pathPattern === '~') {
|
||||
normalizedPath = homedir();
|
||||
}
|
||||
else if (pathPattern.startsWith('~/')) {
|
||||
normalizedPath = homedir() + pathPattern.slice(1);
|
||||
}
|
||||
else if (pathPattern.startsWith('./') || pathPattern.startsWith('../')) {
|
||||
// Convert relative to absolute based on current working directory
|
||||
normalizedPath = path.resolve(cwd, pathPattern);
|
||||
}
|
||||
else if (!path.isAbsolute(pathPattern)) {
|
||||
// Handle other relative paths (e.g., ".", "..", "foo/bar")
|
||||
normalizedPath = path.resolve(cwd, pathPattern);
|
||||
}
|
||||
// For glob patterns, resolve symlinks for the directory portion only
|
||||
if (containsGlobChars(normalizedPath)) {
|
||||
// Extract the static directory prefix before glob characters
|
||||
const staticPrefix = normalizedPath.split(/[*?[\]]/)[0];
|
||||
if (staticPrefix && staticPrefix !== '/') {
|
||||
// Get the directory containing the glob pattern
|
||||
// If staticPrefix ends with /, remove it to get the directory
|
||||
const baseDir = staticPrefix.endsWith('/')
|
||||
? staticPrefix.slice(0, -1)
|
||||
: path.dirname(staticPrefix);
|
||||
// Try to resolve symlinks for the base directory
|
||||
try {
|
||||
const resolvedBaseDir = fs.realpathSync(baseDir);
|
||||
// Validate that resolution stays within expected boundaries
|
||||
if (!isSymlinkOutsideBoundary(baseDir, resolvedBaseDir)) {
|
||||
// Reconstruct the pattern with the resolved directory
|
||||
const patternSuffix = normalizedPath.slice(baseDir.length);
|
||||
return resolvedBaseDir + patternSuffix;
|
||||
}
|
||||
// If resolution would broaden scope, keep original pattern
|
||||
}
|
||||
catch {
|
||||
// If directory doesn't exist or can't be resolved, keep the original pattern
|
||||
}
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
// Resolve symlinks to real paths to avoid bwrap issues
|
||||
// Validate that the resolution stays within expected boundaries
|
||||
try {
|
||||
const resolvedPath = fs.realpathSync(normalizedPath);
|
||||
// Only use resolved path if it doesn't cross boundary (e.g., symlink to parent dir)
|
||||
if (isSymlinkOutsideBoundary(normalizedPath, resolvedPath)) {
|
||||
// Symlink points outside expected boundaries - keep original path
|
||||
}
|
||||
else {
|
||||
normalizedPath = resolvedPath;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If path doesn't exist or can't be resolved, keep the normalized path
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
/**
|
||||
* Get recommended system paths that should be writable for commands to work properly
|
||||
*
|
||||
* WARNING: These default paths are intentionally broad for compatibility but may
|
||||
* allow access to files from other processes. In highly security-sensitive
|
||||
* environments, you should configure more restrictive write paths.
|
||||
*/
|
||||
export function getDefaultWritePaths() {
|
||||
const homeDir = homedir();
|
||||
const recommendedPaths = [
|
||||
'/dev/stdout',
|
||||
'/dev/stderr',
|
||||
'/dev/null',
|
||||
'/dev/tty',
|
||||
'/dev/dtracehelper',
|
||||
'/dev/autofs_nowait',
|
||||
'/tmp/claude',
|
||||
'/private/tmp/claude',
|
||||
path.join(homeDir, '.npm/_logs'),
|
||||
path.join(homeDir, '.claude/debug'),
|
||||
];
|
||||
return recommendedPaths;
|
||||
}
|
||||
/**
|
||||
* Generate proxy environment variables for sandboxed processes
|
||||
*/
|
||||
export function generateProxyEnvVars(httpProxyPort, socksProxyPort) {
|
||||
// Respect CLAUDE_TMPDIR if set, otherwise default to /tmp/claude
|
||||
const tmpdir = process.env.CLAUDE_TMPDIR || '/tmp/claude';
|
||||
const envVars = [`SANDBOX_RUNTIME=1`, `TMPDIR=${tmpdir}`];
|
||||
// If no proxy ports provided, return minimal env vars
|
||||
if (!httpProxyPort && !socksProxyPort) {
|
||||
return envVars;
|
||||
}
|
||||
// Always set NO_PROXY to exclude localhost and private networks from proxying
|
||||
const noProxyAddresses = [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'*.local',
|
||||
'.local',
|
||||
'169.254.0.0/16', // Link-local
|
||||
'10.0.0.0/8', // Private network
|
||||
'172.16.0.0/12', // Private network
|
||||
'192.168.0.0/16', // Private network
|
||||
].join(',');
|
||||
envVars.push(`NO_PROXY=${noProxyAddresses}`);
|
||||
envVars.push(`no_proxy=${noProxyAddresses}`);
|
||||
if (httpProxyPort) {
|
||||
envVars.push(`HTTP_PROXY=http://localhost:${httpProxyPort}`);
|
||||
envVars.push(`HTTPS_PROXY=http://localhost:${httpProxyPort}`);
|
||||
// Lowercase versions for compatibility with some tools
|
||||
envVars.push(`http_proxy=http://localhost:${httpProxyPort}`);
|
||||
envVars.push(`https_proxy=http://localhost:${httpProxyPort}`);
|
||||
}
|
||||
if (socksProxyPort) {
|
||||
// Use socks5h:// for proper DNS resolution through proxy
|
||||
envVars.push(`ALL_PROXY=socks5h://localhost:${socksProxyPort}`);
|
||||
envVars.push(`all_proxy=socks5h://localhost:${socksProxyPort}`);
|
||||
// Configure Git to use SSH through the proxy so DNS resolution happens outside the sandbox
|
||||
const platform = getPlatform();
|
||||
if (platform === 'macos') {
|
||||
// macOS: use BSD nc SOCKS5 proxy support (-X 5 -x)
|
||||
envVars.push(`GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`);
|
||||
}
|
||||
else if (platform === 'linux' && httpProxyPort) {
|
||||
// Linux: use socat HTTP CONNECT via the HTTP proxy bridge.
|
||||
// socat is already a required Linux sandbox dependency, and PROXY: is
|
||||
// portable across all socat versions (unlike SOCKS5-CONNECT which needs >= 1.8.0).
|
||||
envVars.push(`GIT_SSH_COMMAND=ssh -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=${httpProxyPort}'`);
|
||||
}
|
||||
// FTP proxy support (use socks5h for DNS resolution through proxy)
|
||||
envVars.push(`FTP_PROXY=socks5h://localhost:${socksProxyPort}`);
|
||||
envVars.push(`ftp_proxy=socks5h://localhost:${socksProxyPort}`);
|
||||
// rsync proxy support
|
||||
envVars.push(`RSYNC_PROXY=localhost:${socksProxyPort}`);
|
||||
// Database tools NOTE: Most database clients don't have built-in proxy support
|
||||
// You typically need to use SSH tunneling or a SOCKS wrapper like tsocks/proxychains
|
||||
// Docker CLI uses HTTP for the API
|
||||
// This makes Docker use the HTTP proxy for registry operations
|
||||
envVars.push(`DOCKER_HTTP_PROXY=http://localhost:${httpProxyPort || socksProxyPort}`);
|
||||
envVars.push(`DOCKER_HTTPS_PROXY=http://localhost:${httpProxyPort || socksProxyPort}`);
|
||||
// Kubernetes kubectl - uses standard HTTPS_PROXY
|
||||
// kubectl respects HTTPS_PROXY which we already set above
|
||||
// AWS CLI - uses standard HTTPS_PROXY (v2 supports it well)
|
||||
// AWS CLI v2 respects HTTPS_PROXY which we already set above
|
||||
// Google Cloud SDK - has specific proxy settings
|
||||
// Use HTTPS proxy to match other HTTP-based tools
|
||||
if (httpProxyPort) {
|
||||
envVars.push(`CLOUDSDK_PROXY_TYPE=https`);
|
||||
envVars.push(`CLOUDSDK_PROXY_ADDRESS=localhost`);
|
||||
envVars.push(`CLOUDSDK_PROXY_PORT=${httpProxyPort}`);
|
||||
}
|
||||
// Azure CLI - uses HTTPS_PROXY
|
||||
// Azure CLI respects HTTPS_PROXY which we already set above
|
||||
// Terraform - uses standard HTTP/HTTPS proxy vars
|
||||
// Terraform respects HTTP_PROXY/HTTPS_PROXY which we already set above
|
||||
// gRPC-based tools - use standard proxy vars
|
||||
envVars.push(`GRPC_PROXY=socks5h://localhost:${socksProxyPort}`);
|
||||
envVars.push(`grpc_proxy=socks5h://localhost:${socksProxyPort}`);
|
||||
}
|
||||
// WARNING: Do not set HTTP_PROXY/HTTPS_PROXY to SOCKS URLs when only SOCKS proxy is available
|
||||
// Most HTTP clients do not support SOCKS URLs in these variables and will fail, and we want
|
||||
// to avoid overriding the client otherwise respecting the ALL_PROXY env var which points to SOCKS.
|
||||
return envVars;
|
||||
}
|
||||
/**
|
||||
* Encode a command for sandbox monitoring
|
||||
* Truncates to 100 chars and base64 encodes to avoid parsing issues
|
||||
*/
|
||||
export function encodeSandboxedCommand(command) {
|
||||
const truncatedCommand = command.slice(0, 100);
|
||||
return Buffer.from(truncatedCommand).toString('base64');
|
||||
}
|
||||
/**
|
||||
* Decode a base64-encoded command from sandbox monitoring
|
||||
*/
|
||||
export function decodeSandboxedCommand(encodedCommand) {
|
||||
return Buffer.from(encodedCommand, 'base64').toString('utf8');
|
||||
}
|
||||
/**
|
||||
* Convert a glob pattern to a regular expression
|
||||
*
|
||||
* This implements gitignore-style pattern matching to match the behavior of the
|
||||
* `ignore` library used by the permission system.
|
||||
*
|
||||
* Supported patterns:
|
||||
* - * matches any characters except / (e.g., *.ts matches foo.ts but not foo/bar.ts)
|
||||
* - ** matches any characters including / (e.g., src/**\/*.ts matches all .ts files in src/)
|
||||
* - ? matches any single character except / (e.g., file?.txt matches file1.txt)
|
||||
* - [abc] matches any character in the set (e.g., file[0-9].txt matches file3.txt)
|
||||
*
|
||||
* Exported for testing and shared between macOS sandbox profiles and Linux glob expansion.
|
||||
*/
|
||||
export function globToRegex(globPattern) {
|
||||
return ('^' +
|
||||
globPattern
|
||||
// Escape regex special characters (except glob chars * ? [ ])
|
||||
.replace(/[.^$+{}()|\\]/g, '\\$&')
|
||||
// Escape unclosed brackets (no matching ])
|
||||
.replace(/\[([^\]]*?)$/g, '\\[$1')
|
||||
// Convert glob patterns to regex (order matters - ** before *)
|
||||
.replace(/\*\*\//g, '__GLOBSTAR_SLASH__') // Placeholder for **/
|
||||
.replace(/\*\*/g, '__GLOBSTAR__') // Placeholder for **
|
||||
.replace(/\*/g, '[^/]*') // * matches anything except /
|
||||
.replace(/\?/g, '[^/]') // ? matches single character except /
|
||||
// Restore placeholders
|
||||
.replace(/__GLOBSTAR_SLASH__/g, '(.*/)?') // **/ matches zero or more dirs
|
||||
.replace(/__GLOBSTAR__/g, '.*') + // ** matches anything including /
|
||||
'$');
|
||||
}
|
||||
/**
|
||||
* Expand a glob pattern into concrete file paths.
|
||||
*
|
||||
* Used on Linux where bubblewrap doesn't support glob patterns natively.
|
||||
* Resolves the static directory prefix, lists files recursively, and filters
|
||||
* using globToRegex().
|
||||
*
|
||||
* @param globPath - A path pattern containing glob characters (e.g., ~/test/*.env)
|
||||
* @returns Array of absolute paths matching the glob pattern
|
||||
*/
|
||||
export function expandGlobPattern(globPath) {
|
||||
const normalizedPattern = normalizePathForSandbox(globPath);
|
||||
// Extract the static directory prefix before any glob characters
|
||||
const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0];
|
||||
if (!staticPrefix || staticPrefix === '/') {
|
||||
logForDebugging(`[Sandbox] Glob pattern too broad, skipping: ${globPath}`);
|
||||
return [];
|
||||
}
|
||||
// Get the base directory from the static prefix
|
||||
const baseDir = staticPrefix.endsWith('/')
|
||||
? staticPrefix.slice(0, -1)
|
||||
: path.dirname(staticPrefix);
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
logForDebugging(`[Sandbox] Base directory for glob does not exist: ${baseDir}`);
|
||||
return [];
|
||||
}
|
||||
// Build regex from the normalized glob pattern
|
||||
const regex = new RegExp(globToRegex(normalizedPattern));
|
||||
// List all entries recursively under the base directory
|
||||
const results = [];
|
||||
try {
|
||||
const entries = fs.readdirSync(baseDir, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
// Build the full path for this entry
|
||||
// entry.parentPath is the directory containing this entry (available in Node 20+/Bun)
|
||||
// For compatibility, fall back to entry.path if parentPath is not available
|
||||
const parentDir = entry.parentPath ??
|
||||
entry.path ??
|
||||
baseDir;
|
||||
const fullPath = path.join(parentDir, entry.name);
|
||||
if (regex.test(fullPath)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logForDebugging(`[Sandbox] Error expanding glob pattern ${globPath}: ${err}`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
//# sourceMappingURL=sandbox-utils.js.map
|
||||
54
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-violation-store.js
vendored
Normal file
54
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-violation-store.js
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
import { encodeSandboxedCommand } from './sandbox-utils.js';
|
||||
/**
|
||||
* In-memory tail for sandbox violations
|
||||
*/
|
||||
export class SandboxViolationStore {
|
||||
constructor() {
|
||||
this.violations = [];
|
||||
this.totalCount = 0;
|
||||
this.maxSize = 100;
|
||||
this.listeners = new Set();
|
||||
}
|
||||
addViolation(violation) {
|
||||
this.violations.push(violation);
|
||||
this.totalCount++;
|
||||
if (this.violations.length > this.maxSize) {
|
||||
this.violations = this.violations.slice(-this.maxSize);
|
||||
}
|
||||
this.notifyListeners();
|
||||
}
|
||||
getViolations(limit) {
|
||||
if (limit === undefined) {
|
||||
return [...this.violations];
|
||||
}
|
||||
return this.violations.slice(-limit);
|
||||
}
|
||||
getCount() {
|
||||
return this.violations.length;
|
||||
}
|
||||
getTotalCount() {
|
||||
return this.totalCount;
|
||||
}
|
||||
getViolationsForCommand(command) {
|
||||
const commandBase64 = encodeSandboxedCommand(command);
|
||||
return this.violations.filter(v => v.encodedCommand === commandBase64);
|
||||
}
|
||||
clear() {
|
||||
this.violations = [];
|
||||
// Don't reset totalCount when clearing
|
||||
this.notifyListeners();
|
||||
}
|
||||
subscribe(listener) {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getViolations());
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
notifyListeners() {
|
||||
// Always notify with all violations so listeners can track the full count
|
||||
const violations = this.getViolations();
|
||||
this.listeners.forEach(listener => listener(violations));
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=sandbox-violation-store.js.map
|
||||
95
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.js
vendored
Normal file
95
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.js
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createServer } from '@pondwader/socks5-server';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
export function createSocksProxyServer(options) {
|
||||
const socksServer = createServer();
|
||||
socksServer.setRulesetValidator(async (conn) => {
|
||||
try {
|
||||
const hostname = conn.destAddress;
|
||||
const port = conn.destPort;
|
||||
logForDebugging(`Connection request to ${hostname}:${port}`);
|
||||
const allowed = await options.filter(port, hostname);
|
||||
if (!allowed) {
|
||||
logForDebugging(`Connection blocked to ${hostname}:${port}`, {
|
||||
level: 'error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
logForDebugging(`Connection allowed to ${hostname}:${port}`);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
logForDebugging(`Error validating connection: ${error}`, {
|
||||
level: 'error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return {
|
||||
server: socksServer,
|
||||
getPort() {
|
||||
// Access the internal server to get the port
|
||||
// We need to use type assertion here as the server property is private
|
||||
try {
|
||||
const serverInternal = socksServer?.server;
|
||||
if (serverInternal && typeof serverInternal?.address === 'function') {
|
||||
const address = serverInternal.address();
|
||||
if (address && typeof address === 'object' && 'port' in address) {
|
||||
return address.port;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// Server might not be listening yet or property access failed
|
||||
logForDebugging(`Error getting port: ${error}`, { level: 'error' });
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
listen(port, hostname) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const listeningCallback = () => {
|
||||
const actualPort = this.getPort();
|
||||
if (actualPort) {
|
||||
logForDebugging(`SOCKS proxy listening on ${hostname}:${actualPort}`);
|
||||
resolve(actualPort);
|
||||
}
|
||||
else {
|
||||
reject(new Error('Failed to get SOCKS proxy server port'));
|
||||
}
|
||||
};
|
||||
socksServer.listen(port, hostname, listeningCallback);
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
return new Promise((resolve, reject) => {
|
||||
socksServer.close(error => {
|
||||
if (error) {
|
||||
// Only reject for actual errors, not for "already closed" states
|
||||
// Check for common "already closed" error patterns
|
||||
const errorMessage = error.message?.toLowerCase() || '';
|
||||
const isAlreadyClosed = errorMessage.includes('not running') ||
|
||||
errorMessage.includes('already closed') ||
|
||||
errorMessage.includes('not listening');
|
||||
if (!isAlreadyClosed) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
unref() {
|
||||
// Access the internal server to call unref
|
||||
try {
|
||||
const serverInternal = socksServer?.server;
|
||||
if (serverInternal && typeof serverInternal?.unref === 'function') {
|
||||
serverInternal.unref();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logForDebugging(`Error calling unref: ${error}`, { level: 'error' });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=socks-proxy.js.map
|
||||
25
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/utils/debug.js
vendored
Normal file
25
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/utils/debug.js
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Simple debug logging for standalone sandbox
|
||||
*/
|
||||
export function logForDebugging(message, options) {
|
||||
// Only log if SRT_DEBUG environment variable is set
|
||||
// Using SRT_DEBUG instead of DEBUG to avoid conflicts with other tools
|
||||
// (DEBUG is commonly used by Node.js debug libraries and VS Code)
|
||||
if (!process.env.SRT_DEBUG) {
|
||||
return;
|
||||
}
|
||||
const level = options?.level || 'info';
|
||||
const prefix = '[SandboxDebug]';
|
||||
// Always use stderr to avoid corrupting stdout JSON streams
|
||||
switch (level) {
|
||||
case 'error':
|
||||
console.error(`${prefix} ${message}`);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(`${prefix} ${message}`);
|
||||
break;
|
||||
default:
|
||||
console.error(`${prefix} ${message}`);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=debug.js.map
|
||||
49
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/utils/platform.js
vendored
Normal file
49
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/utils/platform.js
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Platform detection utilities
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
/**
|
||||
* Get the WSL version (1 or 2+) if running in WSL.
|
||||
* Returns undefined if not running in WSL.
|
||||
*/
|
||||
export function getWslVersion() {
|
||||
if (process.platform !== 'linux') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const procVersion = fs.readFileSync('/proc/version', { encoding: 'utf8' });
|
||||
// Check for explicit WSL version markers (e.g., "WSL2", "WSL3", etc.)
|
||||
const wslVersionMatch = procVersion.match(/WSL(\d+)/i);
|
||||
if (wslVersionMatch && wslVersionMatch[1]) {
|
||||
return wslVersionMatch[1];
|
||||
}
|
||||
// If no explicit WSL version but contains Microsoft, assume WSL1
|
||||
// This handles the original WSL1 format: "4.4.0-19041-Microsoft"
|
||||
if (procVersion.toLowerCase().includes('microsoft')) {
|
||||
return '1';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Detect the current platform.
|
||||
* Note: All Linux including WSL returns 'linux'. Use getWslVersion() to detect WSL1 (unsupported).
|
||||
*/
|
||||
export function getPlatform() {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return 'macos';
|
||||
case 'linux':
|
||||
// WSL2+ is treated as Linux (same sandboxing)
|
||||
// WSL1 is also returned as 'linux' but will fail isSupportedPlatform check
|
||||
return 'linux';
|
||||
case 'win32':
|
||||
return 'windows';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=platform.js.map
|
||||
45
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/utils/ripgrep.js
vendored
Normal file
45
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/utils/ripgrep.js
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { text } from 'node:stream/consumers';
|
||||
import { whichSync } from './which.js';
|
||||
/**
|
||||
* Check if ripgrep (rg) is available synchronously
|
||||
* Returns true if rg is installed, false otherwise
|
||||
*/
|
||||
export function hasRipgrepSync() {
|
||||
return whichSync('rg') !== null;
|
||||
}
|
||||
/**
|
||||
* Execute ripgrep with the given arguments
|
||||
* @param args Command-line arguments to pass to rg
|
||||
* @param target Target directory or file to search
|
||||
* @param abortSignal AbortSignal to cancel the operation
|
||||
* @param config Ripgrep configuration (command and optional args)
|
||||
* @returns Array of matching lines (one per line of output)
|
||||
* @throws Error if ripgrep exits with non-zero status (except exit code 1 which means no matches)
|
||||
*/
|
||||
export async function ripGrep(args, target, abortSignal, config = { command: 'rg' }) {
|
||||
const { command, args: commandArgs = [], argv0 } = config;
|
||||
const child = spawn(command, [...commandArgs, ...args, target], {
|
||||
argv0,
|
||||
signal: abortSignal,
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
text(child.stdout),
|
||||
text(child.stderr),
|
||||
new Promise((resolve, reject) => {
|
||||
child.on('close', resolve);
|
||||
child.on('error', reject);
|
||||
}),
|
||||
]);
|
||||
if (code === 0) {
|
||||
return stdout.trim().split('\n').filter(Boolean);
|
||||
}
|
||||
if (code === 1) {
|
||||
// Exit code 1 means "no matches found" - this is normal
|
||||
return [];
|
||||
}
|
||||
throw new Error(`ripgrep failed with exit code ${code}: ${stderr}`);
|
||||
}
|
||||
//# sourceMappingURL=ripgrep.js.map
|
||||
25
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/utils/which.js
vendored
Normal file
25
claude-code-source/stubs/@anthropic-ai/sandbox-runtime/dist/utils/which.js
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
/**
|
||||
* Find the path to an executable, similar to the `which` command.
|
||||
* Uses Bun.which when running in Bun, falls back to spawnSync for Node.js.
|
||||
*
|
||||
* @param bin - The name of the executable to find
|
||||
* @returns The full path to the executable, or null if not found
|
||||
*/
|
||||
export function whichSync(bin) {
|
||||
// Check if we're running in Bun
|
||||
if (typeof globalThis.Bun !== 'undefined') {
|
||||
return globalThis.Bun.which(bin);
|
||||
}
|
||||
// Fallback to Node.js implementation
|
||||
const result = spawnSync('which', [bin], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1000,
|
||||
});
|
||||
if (result.status === 0 && result.stdout) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//# sourceMappingURL=which.js.map
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// SandboxManager stub - static class-like object
|
||||
export const SandboxManager = {
|
||||
isSupportedPlatform() { return false },
|
||||
checkDependencies() { return { errors: [], warnings: [] } },
|
||||
async initialize(_config, callback) { if (callback) await callback() },
|
||||
updateConfig(_config) {},
|
||||
async reset() {},
|
||||
async wrapWithSandbox(_config, fn) { return fn() },
|
||||
getFsReadConfig() { return null },
|
||||
getFsWriteConfig() { return null },
|
||||
getNetworkRestrictionConfig() { return null },
|
||||
getIgnoreViolations() { return null },
|
||||
}
|
||||
|
||||
export const SandboxRuntimeConfigSchema = z.object({}).passthrough()
|
||||
|
||||
export class SandboxViolationStore {
|
||||
add() {}
|
||||
getAll() { return [] }
|
||||
clear() {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"@anthropic-ai/sandbox-runtime","version":"1.0.0","type":"module","main":"index.js","exports":{".":"./index.js"}}
|
||||
14
claude-code-source/stubs/color-diff-napi/index.js
Normal file
14
claude-code-source/stubs/color-diff-napi/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Stub: color-diff-napi is a private native module for syntax highlighting
|
||||
export class ColorDiff {
|
||||
constructor() {}
|
||||
diff() { return [] }
|
||||
}
|
||||
|
||||
export class ColorFile {
|
||||
constructor() {}
|
||||
getColors() { return [] }
|
||||
}
|
||||
|
||||
export function getSyntaxTheme() { return null }
|
||||
|
||||
export const SyntaxTheme = {}
|
||||
1
claude-code-source/stubs/color-diff-napi/package.json
Normal file
1
claude-code-source/stubs/color-diff-napi/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"color-diff-napi","version":"1.0.0","type":"module","main":"index.js","exports":{".":"./index.js"}}
|
||||
3
claude-code-source/stubs/modifiers-napi/index.js
Normal file
3
claude-code-source/stubs/modifiers-napi/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getModifiers() { return [] }
|
||||
export function isModifierPressed() { return false }
|
||||
export function prewarm() {}
|
||||
1
claude-code-source/stubs/modifiers-napi/package.json
Normal file
1
claude-code-source/stubs/modifiers-napi/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"modifiers-napi","version":"1.0.0","type":"module","main":"index.js","exports":{".":"./index.js"}}
|
||||
Reference in New Issue
Block a user