{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","overall_status":"completed","scanned_at":"2026-10-22T05:25:57.657Z","scan_summary":{"total_components":22,"worst_case_severity":"CRITICAL","repo_trust_score":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]}},"components":[{"artifact_version":"2.0","component":{"component_type":"mcp-server","component_name":"mcp-server","component_path":".","metadata":{"transports":["stdio"]}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-M0001","rule_version":"1.2.0","rule_name":"Tool Definition Integrity & Versioning (ETDI-style)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"medium","rationale":"Every shipped launch configuration for this server (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json) invokes it via `npx -y @wonderwhy-er/desktop-commander@latest`. Tool definitions (names/descriptions/schemas) are compiled into the package and are not signed; because launch is unpinned, each MCP client start can silently fetch a newer release whose tool JSON differs from what the user last reviewed, with no hash/signature check and no re-approval step before the new definitions take effect.","vulnerability_details":"A compromised or malicious publish to the @wonderwhy-er/desktop-commander npm package would be picked up by every client on its next launch (since npx -y always resolves @latest), silently changing tool behavior/schemas without any user re-approval gate in the client-facing manifests.","fix_guidance":"Pin the launch config to an exact version (e.g. @wonderwhy-er/desktop-commander@0.3.0) instead of @latest, and adopt the ETDI pattern of signed tool manifests plus an explicit re-approval prompt on tool definition changes.","why_this_matters":"Prevents rug-pull tool poisoning.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-M0001 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":".mcp.json","start_line":1,"end_line":6,"snippet":"{\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }\n}"},{"path":"plugins/cursor/.cursor-plugin/plugin.json","start_line":35,"end_line":40,"snippet":"\"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }"},{"path":"src/server.ts","start_line":306,"end_line":316,"snippet":"server.setRequestHandler(ListToolsRequestSchema, async () => {\n    try {\n        const showMcpUiPreviews = await shouldShowMcpUiPreviews();\n        const allTools = [\n            {\n                name: \"get_config\","}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0002","rule_version":"1.1.0","rule_name":"Unrestricted Tool Permissions (Overprivileged Tools)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"Desktop Commander ships with unrestricted filesystem access by default (allowedDirectories: []) and a denylist-only command filter, consistent with SECURITY.md's description of the server as trusted, privileged local automation rather than a sandboxed tool. The command validator fails closed on validation errors, and set_config_value restricts mutation to an allowed set of keys, providing partial containment beyond the base denylist. No per-tool role separation or capability scoping exists, so a caller directed by untrusted content can still reach the shell and widen file-system or command scope beyond any single call's stated purpose; prompt-injection exploitation of this path was not itself reproduced.","vulnerability_details":"Any content that reaches the connected model (a malicious file the model is asked to read, a poisoned search result, etc.) can direct it to call start_process with an unblocked interpreter, or to call set_config_value to remove the remaining guardrails, then perform unrestricted file read/write and command execution with no additional gate.","fix_guidance":"Default allowedDirectories to a narrow, explicit scope rather than '[] = full disk'; move from a command denylist to an opt-in allowlist for start_process where feasible; require a distinct, non-model-triggerable confirmation step before set_config_value can widen allowedDirectories/blockedCommands.","why_this_matters":"Limits blast radius and privilege escalation.","context_note":"[capability_by_design|relief:-60%] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. Realized severity MEDIUM (from HIGH: 60% context relief -- within_declared_contract -40%, mitigation_present -40%, capped at -60%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/config-manager.ts","start_line":231,"end_line":286,"snippet":"  private getDefaultConfig(): ServerConfig {\n    return {\n      blockedCommands: [ ... ~30 entries ... ],\n\n        // Disk and partition management\n        \"mkfs\",      // Create a filesystem on a device\n        \"format\",    // Format a storage device (cross-platform)\n        \"mount\",     // Mount a filesystem\n      ...\n        \"fdisk\",     // Manipulate disk partition tables\n        \"dd\",        // Convert and copy files, can write directly to disks"},{"path":"src/command-manager.ts","start_line":234,"end_line":267,"snippet":"async validateCommand(command: string): Promise<boolean> {\n    const config = await configManager.getConfig();\n    const blockedCommands = config.blockedCommands || [];\n    const allCommands = this.extractCommands(command);\n    for (const cmd of allCommands) {\n        if (blockedCommands.includes(cmd)) {\n            return false;\n        }\n    }\n    return true;\n}"},{"path":"src/server.ts","start_line":339,"end_line":365,"snippet":"name: \"set_config_value\",\ndescription: `\n        Set a specific configuration value by key.\n        IMPORTANT: Setting allowedDirectories to an empty array ([]) allows full access \n        to the entire file system, regardless of the operating system.\n        ${CMD_PREFIX_DESCRIPTION}`,"},{"path":"SECURITY.md","start_line":15,"end_line":25,"snippet":"| Control | Purpose | Security boundary? |\n|---------|---------|--------------------|\n| Allowed directories | Reduce accidental file access | No |\n| Command blocklist | Reduce accidental execution | No |\nTerminal command execution is a first-class feature. Because it can launch arbitrary programs, path-based and command-based restrictions can be circumvented by design"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0003","rule_version":"1.3.1","rule_name":"CORS / Origin / Host Validation (DNS Rebinding Defense)","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's MCP protocol surface is stdio-only. src/index.ts constructs a FilteredStdioServerTransport and calls server.connect(transport) — there is no HTTP/SSE/StreamableHTTP listener for the MCP protocol itself anywhere in the codebase (grep for .listen(/createServer/express() across src/ only finds an ephemeral 127.0.0.1-bound helper server for PDF rendering, unrelated to MCP transport). Rule applicability requires_remote_transport=true, which is not met.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents DNS rebinding/browser hijack of local or remote MCP.","context_note":null,"within_declared_contract":null,"transport_gated":true,"mitigation_present":null,"evidence":[{"path":"src/index.ts","start_line":55,"end_line":61,"snippet":"// Create transport FIRST so all logging gets properly buffered\n// This must happen before any code that might use logger.*\nconst transport = new FilteredStdioServerTransport();\n\n// Export transport for use throughout the application\nglobal.mcpTransport = transport;"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0004","rule_version":"1.2.0","rule_name":"Binding to 0.0.0.0 Without Auth/TLS","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No network listener is bound for the MCP protocol; the only createServer()/.listen() call in the repo is an ephemeral helper bound explicitly to 127.0.0.1 for local PDF/Chrome rendering (src/tools/pdf/markdown.ts:608,621), not an MCP transport. requires_remote_transport=true is not satisfied.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"LAN/Internet exposure enables remote exploitation.","context_note":null,"within_declared_contract":null,"transport_gated":true,"mitigation_present":null,"evidence":[{"path":"src/tools/pdf/markdown.ts","start_line":605,"end_line":622,"snippet":"const server = createServer((request, response) => {\n ...\n});\n...\nserver.listen(0, '127.0.0.1', resolve);"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0005","rule_version":"1.4.0","rule_name":"OAuth 2.1 + PKCE Correctness (MCP Authorization)","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The MCP server itself exposes only a stdio transport (src/index.ts, FilteredStdioServerTransport) — no streamable_http/SSE/HTTP transport exists for MCP clients, so the MCP Authorization spec's OAuth requirement does not attach to this surface. The separate 'remote' subcommand (src/remote-device/) is an optional outbound bridge that uses OAuth2 Device Authorization Grant + PKCE (src/remote-device/device-authenticator.ts) against the vendor's own hosted backend to relay tool calls over Supabase Realtime — this is a device/client flow with PKCE already implemented, not the MCP client↔server transport the rule targets, and does not change the base server's stdio-only classification.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Misconfig enables token theft and confused-deputy attacks.","context_note":null,"within_declared_contract":null,"transport_gated":true,"mitigation_present":null,"evidence":[{"path":"src/index.ts","start_line":26,"end_line":57,"snippet":"async function runServer() {\n  try {\n    if (process.argv[2] === 'setup') { ... }\n    if (process.argv[2] === 'remove') { ... }\n    if (process.argv[2] === 'remote') {\n      await runRemote();\n      return;\n    }\n    ...\n    const transport = new FilteredStdioServerTransport();"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0006","rule_version":"1.2.0","rule_name":"Tool Name Collision / Spoofing","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"Desktop Commander registers filesystem tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info, read_multiple_files) that overlap names used by the official MCP reference filesystem server, but every shipped launch configuration (.mcp.json, Claude and Cursor plugin manifests) declares only the desktop-commander server identity, and the tool registration code builds its list independently with no merge against, or collision check with, another server's names. No client-side merge, silent shadowing, or bypass of server-qualified routing is present in the shipped configurations, so the overlapping names alone do not establish an exploitable collision in this deployment.","vulnerability_details":"A client connecting both Desktop Commander and the official filesystem MCP server simultaneously faces an unresolved name collision on at least 7 tool names; depending on the client's registration/merge order, one server's filesystem tool can silently shadow or be shadowed by the other, letting a maliciously-substituted or compromised sibling server intercept calls the user believed were going to the trusted one.","fix_guidance":null,"why_this_matters":"Prevents malicious tool shadowing.","context_note":null,"within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/server.ts","start_line":369,"end_line":371,"snippet":"{\n    name: \"read_file\",\n    description: `"},{"path":"src/server.ts","start_line":456,"end_line":458,"snippet":"{\n    name: \"write_file\",\n    description: `"},{"path":"src/server.ts","start_line":306,"end_line":317,"snippet":"server.setRequestHandler(ListToolsRequestSchema, async () => {\n    try {\n        const showMcpUiPreviews = await shouldShowMcpUiPreviews();\n        const allTools = [\n            // Configuration tools\n            {\n                name: \"get_config\",\n                description: `"},{"path":"src/server.ts","start_line":549,"end_line":609,"snippet":"name: \"create_directory\", ...\nname: \"list_directory\", ...\nname: \"move_file\","}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0007","rule_version":"1.2.0","rule_name":"Prompt/Metadata Injection in Tool Descriptions","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Manual review of every tool description string in src/server.ts (and the shared CMD_PREFIX_DESCRIPTION / SERVER_INSTRUCTIONS constants) found only functional usage text, no hidden directives, no zero-width/bidi control characters (checked programmatically for U+200B/U+200C/U+200D/U+202E/U+202A/U+2066/U+2069/U+FEFF across all tool-facing strings), and no ANSI escape sequences or 'ignore previous instructions'-style covert directives. Descriptions only explain functional behavior (pagination, format handling, security notes for set_config_value) in plain language surfaced identically to what a developer reading the source sees.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents covert model manipulation.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":340,"end_line":357,"snippet":"description: `\n        Set a specific configuration value by key.\n        \n        Security note: configuration changes affect later file and command behavior.\n        Isolating them from untrusted file or command content reduces the chance that external content influences configuration.\n        \n        Config keys include: ...`"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0008","rule_version":"1.2.0","rule_name":"CORS Misuse: Wildcard with Credentials","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No HTTP/CORS surface exists for the MCP protocol (stdio-only); requires_remote_transport=true is not met.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents credentialed cross-origin abuse.","context_note":null,"within_declared_contract":null,"transport_gated":true,"mitigation_present":null,"evidence":[{"path":"src/index.ts","start_line":55,"end_line":57,"snippet":"const transport = new FilteredStdioServerTransport();\nglobal.mcpTransport = transport;"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0009","rule_version":"1.3.0","rule_name":"Transport Security: TLS Everywhere (Remote)","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"MCP transport is local stdio only; no remote listener requiring TLS exists in this component. requires_remote_transport=true is not met.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"MitM can steal tokens and tamper responses.","context_note":null,"within_declared_contract":null,"transport_gated":true,"mitigation_present":null,"evidence":[{"path":"src/index.ts","start_line":55,"end_line":57,"snippet":"const transport = new FilteredStdioServerTransport();\nglobal.mcpTransport = transport;"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0010","rule_version":"1.4.0","rule_name":"No Authentication or Authorization Checks (General)","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Rule requires a remote transport (requires_remote_transport=true). The MCP server binds no network listener at all — it communicates over stdio, which is gated by the OS process boundary (whatever spawns the process, e.g. Claude Desktop). No bare ':PORT' or '0.0.0.0' bind exists anywhere in src/ for the MCP protocol itself.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents unauthorized tool use and data access.","context_note":null,"within_declared_contract":null,"transport_gated":true,"mitigation_present":null,"evidence":[{"path":"src/index.ts","start_line":55,"end_line":57,"snippet":"const transport = new FilteredStdioServerTransport();\nglobal.mcpTransport = transport;"}],"affected_tools":null,"component_name":"mcp-server"},{"rule_id":"ATR-M0011","rule_version":"1.2.0","rule_name":"OAuth Proxy Per-Client Consent (Confused Deputy Defense)","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander is not an OAuth proxy forwarding authorization to a distinct upstream IdP on behalf of dynamically-registered MCP clients. The only OAuth-like flow in the repo is the optional 'remote device' feature (src/remote-device/device-authenticator.ts), where the local device itself authenticates (Device Authorization Grant + PKCE) directly to the vendor's own backend to enable remote relay of tool calls — there is no MCP-client registration endpoint, no shared upstream client_id proxied on behalf of third-party MCP clients, and no bridge-code minting pattern. Q1/Q2 of the evaluation method do not apply.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Shared upstream client + dynamic MCP-client registration + no per-client consent enables attacker-registered MCP clients to receive bridge codes carrying victim upstream tokens — the documented MCP Confused Deputy attack.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/remote-device/device-authenticator.ts","start_line":56,"end_line":74,"snippet":"async authenticate(deviceId?: string): Promise<AuthSession> {\n    console.log('🔐 Connecting this computer...\\n');\n    const pkce = this.generatePKCE();\n    const deviceAuth = await this.requestDeviceCode(pkce.challenge, deviceId);\n    this.displayUserInstructions(deviceAuth);\n    const tokens = await this.pollForAuthorization(deviceAuth, pkce.verifier);\n    console.log('✅ Device verified\\n');\n    return tokens;\n}"}],"affected_tools":null,"component_name":"mcp-server"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[{"tool_name":"create_directory","tool_description":"Create a new directory","types":["write"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"edit_block","tool_description":"Apply exact-match find/replace edits to a file","types":["write","destructive"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"force_terminate","tool_description":"Terminate a process session started by start_process","types":["destructive"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_config","tool_description":"Get the complete server configuration as JSON (blockedCommands, allowedDirectories, etc.)","types":["read-only","admin-config"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_file_info","tool_description":"Get metadata about a file or directory","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_more_search_results","tool_description":"Page through results of an in-progress search session","types":["read-only","untrusted-output"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_recent_tool_calls","tool_description":"Get history of recent tool calls with arguments/outputs","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"get_usage_stats","tool_description":"Get usage statistics for the server","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"give_feedback_to_desktop_commander","tool_description":"Send feedback about the tool to the developers","types":["social"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"interact_with_process","tool_description":"Send input to a running process/REPL session","types":["exec-capable","destructive"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"kill_process","tool_description":"Kill an arbitrary OS process by PID","types":["destructive"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"list_directory","tool_description":"List contents of a directory","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"list_processes","tool_description":"List OS processes","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"list_searches","tool_description":"List active search sessions","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"list_sessions","tool_description":"List active terminal sessions","types":["read-only"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"move_file","tool_description":"Move or rename a file/directory","types":["write","destructive"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"read_file","tool_description":"Read contents from files and URLs, including PDFs, images, Excel, DOCX","types":["read-only","untrusted-output","network-broad"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"read_multiple_files","tool_description":"Read multiple files at once","types":["read-only","untrusted-output"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"read_process_output","tool_description":"Read output from a running process session","types":["read-only","untrusted-output"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"set_config_value","tool_description":"Set a specific configuration value by key, including allowedDirectories and blockedCommands","types":["admin-config","destructive"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"start_process","tool_description":"Start a terminal process/shell/REPL session and run a command","types":["exec-capable","destructive","secrets-access","network-broad"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"start_search","tool_description":"Start a ripgrep-backed file/content search session","types":["read-only","untrusted-output"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"stop_search","tool_description":"Stop an in-progress search session","types":["write"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"write_file","tool_description":"Write content to a file","types":["write","destructive"],"status":"IMPLEMENTED","component_name":"mcp-server"},{"tool_name":"write_pdf","tool_description":"Render markdown to PDF or modify existing PDFs","types":["write","destructive"],"status":"IMPLEMENTED","component_name":"mcp-server"}],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":2,"secure":2,"not_applicable":7},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{"write":6,"destructive":9,"read_only":13,"admin_config":2,"untrusted_output":5,"social":1,"exec_capable":2,"network_broad":2,"secrets_access":1},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":32,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":20,"high":44},"factors":["ATR-M0001 HIGH"]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins","component_path":".","metadata":{"manifest":"multiple SKILL.md under skills/ and plugins/*/skills/"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Reviewed all skill artifacts in scope at repo root not individually registered (desktop-commander-overview/SKILL.md, terminal/SKILL.md, identically duplicated under skills/, plugins/claude/skills/, plugins/cursor/skills/) plus the repo's plugin/MCP manifests (plugin.json, plugin.yaml, gemini-extension.json, .mcp.json, manifest.template.json, config.json). desktop-commander-overview and terminal both describe the exact tool surface the Desktop Commander MCP exposes (process/shell, file I/O, search, SSH) -- the manifests declare the same capability set (terminal-control, file-search, file-system-operations, excel/pdf/docx support) that the skill text uses. No undisclosed network egress, credential harvesting, or hidden exec capability found in any model-facing carrier.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":28,"snippet":"description: Use for Desktop Commander MCP capabilities — persistent shells and\n  REPLs, long-running processes, filesystem beyond the workspace, structured\n  files (.xlsx, .docx, .pdf, images) and large local data files such as CSVs,\n  ripgrep search at scale, SSH, or cross-turn state.\n# Desktop Commander MCP\nDesktop Commander gives the agent reach across the user's actual computer —\nfiles, folders, terminals, processes, structured documents, and remote\nmachines reachable over SSH.","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"A YARA signature (Zeus_Metadata_Consent_Suppression) flagged a cluster at lines 20-84 of desktop-commander-overview/SKILL.md (identically present under skills/, plugins/claude/skills/, and plugins/cursor/skills/). Manual review of that exact cluster (the attestation object was not available in this session) shows the opposite of consent suppression: the text explicitly instructs the host to OBTAIN confirmation before privileged actions -- 'Before running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial' (line 36) and 'Before deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation' (line 42). This is a negative-indicator match (prose explicitly defers to host consent gate) and the YARA hit is assessed as a false positive on this reading. The terminal/SKILL.md (also only at root/plugin level, not individually registered) similarly instructs 'Never run destructive commands without explicit confirmation' and devotes a full safety section (S7) to requiring confirmation before destructive commands. No consent-suppression prose was found anywhere in the root-level skill/plugin manifests or instruction bodies.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\nBefore running production-impacting SSH commands, explain the intended\naction and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\nBefore deploys, restarts, migrations, or other environment-changing\ncommands, summarize the action and confirm with the user unless they\nalready explicitly asked for that exact operation.","vulnerability":{}},{"path":"skills/terminal/SKILL.md","start_line":149,"end_line":158,"snippet":"## 7. Safety: destructive commands and chaining\nBeyond that list, treat these as **destructive — never run without the\nuser's explicit confirmation**, even if they'd technically succeed:\n- Recursive/forced deletes: `rm -rf`..."}],"affected_tools":null,"component_name":"skills-plugins"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The onboarding workflow in desktop-commander-overview/SKILL.md explicitly uses get_recent_tool_calls to retrieve prior tool arguments and outputs for last-session reconstruction (lines 90-92). This constitutes a conversation/tool-history read, which brings the artifact within this rule's mechanism-agnostic context-read test. The recovered history stays in-session and is returned to the user; no instruction directs it to any external destination, so no harvest-plus-exfiltration pair exists.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":196,"end_line":200,"snippet":"## Operating etiquette\n- Preview risky commands before running; run read-only inspection first.\n- Use absolute paths; respect `allowedDirectories`.\n- Never print secrets, tokens, or passwords into output; redact if they appear."}],"affected_tools":null,"component_name":"skills-plugins"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"medium","rationale":"The root ships a format-native install-time lifecycle primitive via package.json's postinstall hook, which runs track-installation.js and verify-ripgrep.js. track-installation.js only reads an existing client ID or generates an in-memory fallback UUID when none exists; it does not create a persisted ID file or config directory. verify-ripgrep.js resolves the bundled ripgrep binary and repairs its executable permissions (chmod 0755 on Unix) as part of normal installation -- a purpose-matched executable repair, not a covert directive or payload delivery. Neither script rewrites host control-plane/memory files, exports exec-enabling environment variables, or repoints any LLM provider endpoint. No covert persistence or endpoint-substitution was found.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"package.json","start_line":30,"end_line":30,"snippet":"\"postinstall\": \"node dist/track-installation.js && node dist/npm-scripts/verify-ripgrep.js || node -e \\\"process.exit(0)\\\"\""},{"path":"track-installation.js","start_line":76,"end_line":78,"snippet":"// Telemetry proxy configuration (same as setup script)\nconst TELEMETRY_PROXY_URL = 'https://telemetry.desktopcommander.app/mp/collect';\n[REDACTED]"},{"path":".mcp.json","start_line":1,"end_line":6,"snippet":"{\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }\n}"}],"affected_tools":null,"component_name":"skills-plugins"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"gemini-extension.json declares 'excludeTools': [] (no tools excluded = full MCP toolset available), and package manifests declare no narrower allowed-tools/OAuth scope. However, this is broad-but-justified: the product's entire declared purpose across every manifest (plugin.json, plugin.yaml, gemini-extension.json, server.yaml) IS full terminal/filesystem/process-management access ('MCP server for terminal commands, process management, and file operations'), disclosed prominently in README/FAQ/PRIVACY.md, so the broad grant matches the documented function rather than exceeding it. No wildcard grant is paired with a narrower stated purpose (the false-positive guard for intrinsic broad capability applies). The desktop-commander-overview and terminal skill texts (no allowed-tools frontmatter) likewise only describe using the already-disclosed, purpose-matched tool surface.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"gemini-extension.json","start_line":1,"end_line":9,"snippet":"{\n  \"name\": \"desktop-commander\",\n  \"version\": \"1.5.0\",\n  \"description\": \"MCP server for terminal commands, process management, and file operations across text, code, PDF, DOCX, Excel, images, and structured data\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander\"]\n    }\n  },\n  \"excludeTools\": []\n}"}],"affected_tools":null,"component_name":"skills-plugins"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Codepoint-scanned desktop-commander-overview/SKILL.md and terminal/SKILL.md (identically present under skills/, plugins/claude/skills/, plugins/cursor/skills/) plus the root plugin/MCP manifests (plugin.json, plugin.yaml, gemini-extension.json, .mcp.json, manifest.template.json, config.json, server.yaml, smithery.yaml). No zero-width/bidi/Tag-block codepoints, mixed-script confusables, forged chat-template/role tokens, or ANSI/OSC escapes were found in any model-facing carrier; no HTML-comment/CSS-hidden concealment patterns. The YARA hit on desktop-commander-overview/SKILL.md (addressed under ATR-P0002) was reviewed in full -- it is plain, visible, ASCII prose requiring user confirmation, with no forged/concealed content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":10,"snippet":"(codepoint scan of desktop-commander-overview/SKILL.md and terminal/SKILL.md: no concealment codepoints, no forged chat-template tokens, no HTML-comment/CSS-hidden text, no ANSI escapes found)","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The repo's shipped plugin/MCP manifests launch the Desktop Commander MCP server with an unpinned package reference: the root .mcp.json (also referenced by the root plugin.json), the Claude plugin manifest, and the Cursor plugin manifest all run `npx -y @wonderwhy-er/desktop-commander@latest`, and gemini-extension.json omits any version at all. npx may reuse a cached resolution rather than always fetching fresh, but the launcher is still unpinned to an exact version or digest, so a later publisher-controlled or compromised publish can still be resolved and run without any re-consent from the user. No digest is recorded at approval time and no re-consent mechanism exists on change.","vulnerability_details":null,"fix_guidance":"Pin every shipped mcpServers launch entry (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact published version or digest (e.g. @wonderwhy-er/desktop-commander@0.3.0) instead of @latest or an unversioned reference, and bump the pin (with a changelog/re-consent prompt) on each intentional upgrade so a compromised or unexpected publish cannot silently change what every installed client launches.","why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-P0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":true,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":".mcp.json","start_line":1,"end_line":6,"snippet":"{\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }\n}"},{"path":"plugins/claude/.claude-plugin/plugin.json","start_line":20,"end_line":25,"snippet":"\"skills\": \"./skills/\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }"},{"path":"plugins/cursor/.cursor-plugin/plugin.json","start_line":33,"end_line":40,"snippet":"\"skills\": \"./skills/\",\n  \"rules\": \"./rules/\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }"},{"path":"gemini-extension.json","start_line":1,"end_line":10,"snippet":"{\n  \"name\": \"desktop-commander\",\n  \"version\": \"1.5.0\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander\"]\n    }\n  },\n  \"excludeTools\": []\n}"}],"affected_tools":null,"component_name":"skills-plugins"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo's own declared identity ('desktop-commander' / 'Desktop Commander' / '@wonderwhy-er/desktop-commander') is published from the verifiable genuine owner -- git remote origin is github.com/wonderwhy-er/DesktopCommanderMCP, matching the publisher named in every manifest (plugin.json author, package.json, server.json repository field). No near-miss/homoglyph of a well-known brand, no trust-implying prefix, no version-spoofing suffix, and no fork/official-replacement claims against a third party were found across the root-level manifests (plugin.json, plugin.yaml, gemini-extension.json, server.json, server.yaml, smithery.yaml) or the desktop-commander-overview/terminal skill text.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"server.json","start_line":1,"end_line":10,"snippet":"{\n  \"name\": \"io.github.wonderwhy-er/desktop-commander\",\n  \"title\": \"Desktop Commander\",\n  \"repository\": {\n    \"url\": \"https://github.com/wonderwhy-er/DesktopCommanderMCP\",\n    \"source\": \"github\"\n  },\n  \"version\": \"0.3.0\""}],"affected_tools":null,"component_name":"skills-plugins"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":1,"secure":7,"not_applicable":0},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":54,"tier":"low","capability_band":"minimal","score_confidence":"medium","score_interval":{"low":41,"high":67},"factors":["ATR-P0007 HIGH"]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/claude","component_path":"plugins/claude","metadata":{"manifest":"plugins/claude/.claude-plugin/plugin.json","kind":"plugin-bundle"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The plugin manifest declares exactly one capability class: launching the Desktop Commander MCP server for terminal/process/file operations. All six bundled skills only direct the model to call that MCP's own already-registered tools for exactly the workflows the manifest/README describe. No bundled script, no outbound network call to a non-documented host, no undisclosed credential read, and no undeclared subprocess/eval was found (only SKILL.md/README.md/plugin.json files exist under plugins/claude).","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/.claude-plugin/plugin.json","start_line":1,"end_line":24,"snippet":"{\n  \"name\": \"desktop-commander\",\n  \"description\": \"MCP server for terminal commands, process management, and file operations...\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }\n}"},{"path":"plugins/claude/README.md","start_line":5,"end_line":13,"snippet":"## Components\n\n- MCP server: `desktop-commander`...\n- Skill: `desktop-commander-overview`\n- Skill: `terminal`\n- Skill: `computer-health-check`\n- Skill: `ai-tools-setup`\n- Skill: `knowledge-base`\n- Skill: `obsidian-vault`"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The plugin manifest registers only skill files and the MCP server launch; it contains no approval-bypass or consent-suppression instructions. The ai-tools-setup skill requires redaction of secrets and backups before edits, and the knowledge-base skill asks clarifying questions for ambiguous tasks, but neither skill contains an explicit per-action confirmation requirement. The verdict rests on the absence of any affirmative consent-suppression or self-authorizing language in the bundled instructions, not on an explicit all-write confirmation guarantee.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/SKILL.md","start_line":39,"end_line":43,"snippet":"   concrete working alternative instead of retrying the same thing.\n4. **Never echo secrets.** API keys, tokens, and passwords go *into the right\n   config or `.env` file* — never repeated back in chat. If a user pastes a live\n   secret, warn them, use it, and suggest they rotate it if it was shared"},{"path":"plugins/claude/.claude-plugin/plugin.json","start_line":1,"end_line":24,"snippet":"{\n  \"name\": \"desktop-commander\",\n  \"description\": \"MCP server for terminal commands, process management, and file operations...\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }\n}"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The ai-tools-setup skill reads MCP client configuration files and provider API keys for its declared installation/repair purpose, and additionally performs a provider key validation check via an outbound request to the provider's own endpoint -- this is sanctioned authentication to the user-selected provider, not an exfiltration channel. The desktop-commander-overview skill reads recent tool outputs for its declared overview function. No instructed destination exists for forwarding secrets or conversation history to a third party; output summaries redact secrets and conversation history is not persisted outside the session.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/SKILL.md","start_line":39,"end_line":43,"snippet":"   concrete working alternative instead of retrying the same thing.\n4. **Never echo secrets.** API keys, tokens, and passwords go *into the right\n   config or `.env` file* — never repeated back in chat. If a user pastes a live\n   secret, warn them, use it, and suggest they rotate it if it was shared"},{"path":"plugins/claude/skills/ai-tools-setup/references/claude-desktop-mcp.md","start_line":93,"end_line":98,"snippet":"## Safety\n\n- Only add MCP servers from sources the user trusts; an MCP server runs with the\n  user's privileges.\n- Back up before editing; redact secrets in summaries; restart the client to\n  apply changes."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"plugins/claude ships no lifecycle/event hook (no .claude/settings.json PreToolUse/PostToolUse, no before_tool_callback), no entry-script/dotenv/git-hook files, and no exec-enabling or LLM-endpoint-substituting env assignment. Its only control-plane-adjacent artifact is the declared MCP server launch line in plugin.json (npx -y @wonderwhy-er/desktop-commander@latest), which per this rule's own false-positive guard is 'not by itself format-native out-of-band execution' -- an unpinned launch package is explicitly carved out to ATR-P0007, not this rule. No other primitive exists, so there is no out-of-band execution surface for this rule to evaluate.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/.claude-plugin/plugin.json","start_line":1,"end_line":24,"snippet":"{\n  \"name\": \"desktop-commander\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }\n}"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The plugin manifest declares a single, narrow capability: launching the Desktop Commander MCP server. There is no wildcard/catch-all permission block (no \"permissions\": [\"*\"]\", no broad OAuth scope), and no bundled skill's frontmatter declares an allowed-tools list at all, so there is no self-declared capability class that exceeds the stated purpose. No package.json bin/postinstall or lifecycle hook is shipped.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/.claude-plugin/plugin.json","start_line":1,"end_line":24,"snippet":"{\n  \"name\": \"desktop-commander\",\n  \"description\": \"MCP server for terminal commands, process management, and file operations...\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }\n}"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint-scanned all 24 files under the in-scope component directories (Python regex for zero-width/bidi-override/Tag-block chars U+200B-200D/2060/FEFF/202A-202E/2066-2069/E0000-E007F) -- zero matches. Also grepped for forged chat-template tokens (<|im_start|>, [INST], <start_of_turn>, etc.) and 'ignore previous instructions' style imperatives -- none found. All model-facing text is plain ASCII/UTF-8 prose matching the rendered view.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/.claude-plugin/plugin.json","start_line":1,"end_line":24,"snippet":"(scanned; no zero-width/bidi/tag-block codepoints, no forged chat-template tokens, no homoglyph substitution found)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The plugin's own shipped launch entry starts the Desktop Commander MCP server via `npx -y @wonderwhy-er/desktop-commander@latest` -- an unpinned package reference resolved at run time with no exact version or digest. This matches the rule's explicit positive indicator: an unpinned launch command the bundle ships for the host to run on every start, so each start runs whatever version the registry serves that day with no re-consent and no diff.","vulnerability_details":"The @latest tag in the npx launch argument means the exact code executed as the MCP server is resolved from the npm registry at every invocation rather than pinned to a reviewed version/digest. This is the artifact's own shipped launch entry (not a transitive dependency), so it falls under this rule's launch-package indicator rather than ordinary ATR-C0007 dependency hygiene.","fix_guidance":"Pin the launch command to an exact version or digest, e.g. `npx -y @wonderwhy-er/desktop-commander@0.3.0` (matching the current release tag), and bump it deliberately with each reviewed release rather than floating on @latest.","why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-P0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"plugins/claude/.claude-plugin/plugin.json","start_line":24,"end_line":29,"snippet":"  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The declared identity 'desktop-commander' matches the canonical name of this project's own product (repository wonderwhy-er/DesktopCommanderMCP, the genuine publisher). No near-miss/typosquat of a third-party brand, no trust-implying prefix, no version/tier-spoofing suffix, and no impersonation prose claiming to be an official/updated build of someone else's tool.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/.claude-plugin/plugin.json","start_line":2,"end_line":9,"snippet":"{\n  \"name\": \"desktop-commander\",\n  \"displayName\": \"Desktop Commander\",\n  \"version\": \"0.2.0\",\n  \"description\": \"MCP server for terminal commands, process management, and file operations...\",\n  \"author\": {\n    \"name\": \"Desktop Commander\",\n    \"email\": \"[REDACTED]\"\n  },"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":1,"secure":6,"not_applicable":1},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":89,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":85,"high":93},"factors":["ATR-P0007 HIGH"]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup","component_path":"plugins/claude/skills/ai-tools-setup","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The SKILL.md frontmatter description ('Set up, connect, validate, and repair Claude Desktop and MCP servers using Desktop Commander') matches the body content exactly: golden rules for config editing, backups, and verification, plus three reference files for Claude Desktop/OpenClaw/Hermes setup. No behavior in the body (install/config/diagnose local AI tooling) exceeds or diverges from the declared trigger phrases.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":54,"snippet":"---\nname: ai-tools-setup\n...\ndescription: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers using\n  Desktop Commander ...\n---\n# AI Tools Setup Assistant\nHelp users install, configure, and repair AI tooling on their own machine using\nDesktop Commander's file and terminal tools. ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The skill's golden rules require backing up configs before editing and verifying changes before declaring success; no instruction weakens or bypasses any host confirmation / guardrail. 'Never echo secrets' further reinforces a protective behavior rather than suppressing one.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/SKILL.md","start_line":40,"end_line":52,"snippet":"4. **Never echo secrets.** ...\n5. **Back up before you edit.** Copy the config to a `.bak` first ...\n6. **Verify, then stop.** After a change, actually confirm it worked ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Golden rule #4 explicitly forbids echoing secrets back in chat and requires redaction in any output/logs/summaries; the skill handles API keys only to the extent of testing validity, never collecting or transmitting them elsewhere.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/SKILL.md","start_line":40,"end_line":43,"snippet":"4. **Never echo secrets.** API keys, tokens, and passwords go *into the right\n   config or `.env` file* — never repeated back in chat. If a user pastes a live\n   secret, warn them, use it, and suggest they rotate it if it was shared\n   insecurely. Redact secrets in any output, logs, or summaries you produce."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"SKILL.md and its three reference files are plain Markdown instructions/references containing no bundled execution hooks, entry scripts, or format-native execution primitives. The third-party daemon installation they describe is carried out by a model-selected host tool, not a format-native execution mechanism bundled by this skill, so the rule's subject of bundled execution hooks/entry scripts is not established.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/references/openclaw.md","start_line":94,"end_line":99,"snippet":"## Uninstall / cleanup\n- Stop the service/daemon, then `npm uninstall -g openclaw`.\n- Remove residual files in `~/.openclaw/` (back up first if it holds config the\n  user may want).\n- Disable any autostart entry created during onboarding."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No capability/permission manifest field exists; the breadth of the described task (installing/configuring third-party AI tooling) is consistent with the stated trigger phrases and does not request scope beyond what the task needs.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: ai-tools-setup\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers using\n  Desktop Commander ...\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Scanned SKILL.md and all three references/*.md files for hidden unicode control characters and covert directive phrasing; none found. All instructions are plainly visible and consistent with the stated purpose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/SKILL.md","start_line":25,"end_line":35,"snippet":"## Golden rules (read before doing anything)\n1. **Detect the OS and shell first.** ...\n2. **Verify the model/API key works before you build.** ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"references/hermes.md and references/openclaw.md provide third-party install commands (curl|bash and npm install) for external tools, not this skill's own shipped runtime launch entry or a self-mutating instruction bundle. The skill package itself contains no updater, live-fetch mechanism, or capability to silently rewrite its own trusted instructions, so the provenance/mutation concern this rule targets is not established by third-party installer prose.","vulnerability_details":"Any compromise of hermes-agent.nousresearch.com's install.sh, or a malicious/compromised publish of the openclaw npm package, would be silently pulled in and executed on the user's machine the next time this skill walks the agent through installing either tool, since neither command pins a version or verifies integrity.","fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/references/hermes.md","start_line":22,"end_line":27,"snippet":"**CLI only:**\n\n```bash\n# Linux / macOS / WSL2 / Android (Termux)\ncurl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash\n```"},{"path":"plugins/claude/skills/ai-tools-setup/references/openclaw.md","start_line":15,"end_line":18,"snippet":"```bash\n# Install\nnpm install -g openclaw@latest\n```"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Skill identifies third-party tools (OpenClaw, Hermes) by their real names and links to their real official docs domains (docs.openclaw.ai, hermes-agent.nousresearch.com) rather than impersonating them; the skill itself is clearly scoped and attributed to Desktop Commander, consistent across all manifests.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/ai-tools-setup/SKILL.md","start_line":68,"end_line":77,"snippet":"- **OpenClaw** — a self-hosted multi-channel gateway for AI agents. ...\n  onboard, wire a model/provider, connect channels (Telegram, Slack, iMessage,\n- **Hermes** — disambiguate first: **Hermes Agent** ... vs. **Hermes 3** ...\n  → Read [`references/openclaw.md`](references/openclaw.md)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/ai-tools-setup"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":7,"not_applicable":1},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":90,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/claude/skills/computer-health-check","component_path":"plugins/claude/skills/computer-health-check","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"SKILL.md frontmatter declares a read-only computer health-check skill that uses Desktop Commander's local shell to gather diagnostics and only performs cleanups the user explicitly approves. The body (safety contract, Steps 1-5) maps 1:1 to that declaration: OS detection, read-only collection via start_process, scoring, chat report, and opt-in cleanup. No undisclosed network egress, credential reads, or exec capability beyond what is declared; explicit text forbids sudo/elevation and states 'Privacy. This reads local system metadata only. Dont send anything off the machine.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":5,"end_line":16,"snippet":"description: >-\n  Run a comprehensive, read-only health check on the user's computer...\n  scored chat summary with prioritized, plain-English recommendations and safe cleanup\n  suggestions. Use this whenever the user wants to check their computer's health, speed\n  it up, free up / reclaim disk space, find what's eating CPU / memory / storage, check\n  battery health and wear, audit login & startup items, see pending updates, or asks for\n...\n  healthy\". Trigger even on casual phrasing like \"my laptop feels slow\", \"why is my fan\n  so loud\", \"my computer is laggy\", \"running out of space\", or \"clean up my machine\".\n  Works on macOS, Windows, and Linux. This is a Desktop Commander skill: it relies on a\n  real local shell (start_process / interact_with_process) and is strictly read-only by\n  default — it never needs sudo and only performs cleanups the user explicitly approves."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/computer-health-check"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Instruction body explicitly REQUIRES user consent before any state-changing action: 'Cleanups are opt-in. You may suggest cleanups freely, but only execute one after the user explicitly approves that specific action, and only show the exact command first.' No prose instructs the agent to skip confirmation, hide actions, or claim pre-authorization; brevity guidance ('Keep it tight and scannable') only shapes report formatting, not concealment of privileged actions.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":122,"end_line":135,"snippet":"### Step 5 — Suggest cleanups, execute only on approval\n\nFor each recommendation that maps to a concrete command (see the reference file's \"Cleanup\"\nsection), offer it and show the exact command. Then:\n  cache cleanup...\n- **Safe, reversible** (offer to run on a yes): empty Trash, package-manager cache cleanup\n  login items, installing OS updates...\n  `apt-get clean` / `dnf clean` need root — suggest, don't auto-run.)\n- **Needs the user to decide / act** (suggest, don't auto-run): disabling login items,\n  installing OS updates (requires restart), uninstalling apps, deleting files in Downloads."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/computer-health-check"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"No instruction text reads host credential stores (env secrets, ~/.ssh, ~/.aws, keychain) or the model's conversation/system-prompt context. Per the rule's SCOPE guidance, the harvest half governs applicability: this skill only reads local system diagnostics (disk/memory/battery/process state) and explicitly states 'Privacy. This reads local system metadata only. Don't send anything off the machine.' With no harvest step constructible, no harvest+exfil pair exists, so the rule's subject does not exist in this component.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":160,"end_line":163,"snippet":"- **Privacy.** This reads local system metadata only. Don't send anything off the machine;\n  the public-IP check is optional and should be labeled."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/computer-health-check"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"This component ships only SKILL.md and three reference markdown files -- no bundled scripts, lifecycle/event hooks, entry-point code, dotenv/git-hook files, or env-var assignments. Per the rule's SCOPE paragraph, since the bundle ships none of the format-native execution/control-plane primitives the rule enumerates, there is no OOB-execution surface to evaluate.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":1,"end_line":17,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Run a comprehensive, read-only health check..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/computer-health-check"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"SKILL.md frontmatter declares no allowed-tools/scope/permissions field at all (no wildcard grant, no OAuth/permission block) and ships no bundled executable, package.json bin/postinstall, or code-executing lifecycle hook -- a purely declarative text artifact. The prose explicitly narrows its OWN use of the host's broader tool surface to read-only diagnostics plus opt-in, confirmed cleanups, and explicitly forbids sudo/elevation -- i.e. it self-restricts below what the host grants rather than requesting anything beyond its stated purpose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":31,"end_line":40,"snippet":"## The safety contract (read this first)\n\nThis is the part that makes users trust the skill, so honor it strictly:\n\n- **Read-only by default.** The collection phase only *observes* (sizes, counts, status)."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/computer-health-check"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint scan of SKILL.md for zero-width/bidi/Tag-block characters, mixed-script confusables, forged chat-template tokens, HTML comments, and ANSI escapes found none. The raw bytes are byte-identical to the rendered content; all instruction text is purely descriptive with no host-directed imperatives hidden from a reviewer.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":1,"end_line":20,"snippet":"(codepoint scan: no U+200B/200C/200D/2060/FEFF, no U+202A-202E/2066-2069 bidi overrides, no HTML comments, no forged chat-template tokens, no ANSI escapes found in this file)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/computer-health-check"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"SKILL.md and its reference files ship fully inside the bundle as static markdown -- no runtime fetch of instructions from a mutable endpoint, no self-rewriting install hook, and this component itself ships no MCP server launch entry (that lives one directory up in the parent plugin manifest, evaluated under the root component).","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":1,"end_line":17,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Run a comprehensive, read-only health check..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/computer-health-check"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Name 'computer-health-check' is a plain descriptive name with no near-miss edit-distance to any known brand/tool, no homoglyph/leetspeak substitution, no trust-implying prefix (official-/verified-), no version/tier-spoofing suffix, and no prose claiming to be an official/updated/drop-in replacement for a named legitimate tool. Published by wonderwhy-er (the genuine Desktop Commander project owner, confirmed via git remote origin).","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":1,"end_line":4,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/computer-health-check"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":6,"not_applicable":2},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview","component_path":"plugins/claude/skills/desktop-commander-overview","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Pure documentation describing Desktop Commander's existing MCP tools and maps 1:1 to the declared frontmatter description. No bundled code, no hidden network egress, no undisclosed credential reads.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":28,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities ...\n---\n\n# Desktop Commander MCP\n\nDesktop Commander gives the agent reach across the user's actual computer...","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"A YARA signature (agentic_metadata:Zeus_Metadata_Consent_Suppression, cluster L20-L84) flagged this file. The scanner_attestation object was not available in this session. Manually reading the full cluster L20-L84 shows the opposite of suppression: the text explicitly instructs the agent to GET user confirmation before risky actions ('explain the intended action and get user confirmation when the risk is non-trivial'; 'summarize the action and confirm with the user unless they already explicitly asked for that exact operation'). There is no 'do not tell the user', 'auto-approve', or authority-override language anywhere in the file. This is assessed as a false positive on the signature's part -- the keyword proximity of 'confirm'/'user'/'explicitly' near each other matched the pattern but the semantic content reinforces consent rather than suppressing it.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"SKILL.md lines 82-84 retrieve prior tool arguments and outputs, which is a conversation-context read this rule evaluates. The read remains in-session, with no instruction to transmit or exfiltrate that context outward, so the harvest-and-exfiltration pattern this rule targets is not present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":109,"snippet":"(full file reviewed: content only describes Desktop Commander's shell/file/search/SSH tools and example workflows; no credential-store read or conversation-context dump instruction present)","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"This component is a single SKILL.md with no bundled script, lifecycle hook, dotenv, git hook, or env-var assignment of any kind. It only instructs the model to call already-registered host tools through the normal consent-gated flow, which is explicitly not a format-native primitive per this rule.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":6,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities ...\nversion: 0.1.0\naudience: agent\n---","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"SKILL.md frontmatter declares no allowed-tools/permissions field at all (verified: grep for allowed-tools/permissions/scope across this file returns nothing) -- there is no wildcard or catch-all declared scope to flag.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":6,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities ...\nversion: 0.1.0\naudience: agent\n---","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Codepoint scan found no zero-width/bidi/Tag-block characters and no forged chat-template tokens or homoglyph substitutions in this SKILL.md.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":109,"snippet":"(full file codepoint-scanned: 7-bit-clean ASCII prose aside from standard em-dashes/emoji; no concealment codepoints, no forged system/role tokens)","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"This component is a single static SKILL.md shipped inline in the repo -- it has no launch entry of its own, no runtime instruction fetch, and no self-update mechanism. The launch-pinning concern belongs to the plugin.json manifest (evaluated under the plugins/claude component), not to this skill file.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":6,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities ...\nversion: 0.1.0\n---","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Name 'desktop-commander-overview' is a plain descriptive name for a documentation skill about the repo's own Desktop Commander product, not a near-miss of any other brand.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":4,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities ...","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/desktop-commander-overview"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":6,"not_applicable":2},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/claude/skills/knowledge-base","component_path":"plugins/claude/skills/knowledge-base","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Declared purpose ('Create and maintain a Markdown knowledge base') matches the entire body, which only covers note/index conventions and file read/write/search workflows via Desktop Commander tools. No undeclared capability.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/knowledge-base/SKILL.md","start_line":1,"end_line":25,"snippet":"---\nname: knowledge-base\n...\ndescription: >-\n  Create and maintain a Markdown knowledge base that any AI agent can read,\n  search, and update. ...\n---\n# Knowledge Base (Markdown, agent-readable)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/knowledge-base"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Maintenance workflows do merge and archive notes, but the instructions require reporting these changes to the user and require clarification for ambiguous requests, so there is no language suppressing consent or concealing actions from the user.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/knowledge-base/SKILL.md","start_line":151,"end_line":170,"snippet":"## Workflows\n### Create a new KB\n1. `create_directory` for the folder structure above.\n2. `write_file` `INDEX.md` with the instructions block ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/knowledge-base"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The skill only reads and writes user-authored Markdown notes; it contains no mechanism that reads host credentials, API keys, or live conversation or memory context, so the rule's subject matter is absent rather than present-and-secure.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\n...\ndescription: >-\n  Create and maintain a Markdown knowledge base that any AI agent can read,\n  search, and update. ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/knowledge-base"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The skill is prose-only and invokes only disclosed KB index-writing operations through existing tools; it ships no format-native execution or host control-plane persistence primitive, placing it outside the rule's subject matter.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/knowledge-base/SKILL.md","start_line":151,"end_line":160,"snippet":"## Workflows\n### Create a new KB\n1. `create_directory` for the folder structure above.\n2. `write_file` `INDEX.md` ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/knowledge-base"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No capability/permission manifest field; scope (markdown file CRUD) matches the stated purpose with no broader access requested.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Create and maintain a Markdown knowledge base ...\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/knowledge-base"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No hidden unicode control characters or covert directives found in the file.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\n...\n---\n# Knowledge Base (Markdown, agent-readable)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/knowledge-base"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"This skill contains no package install, launch config, or external-code-execution instructions of any kind — purely local markdown file management.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\n...\n---\n# Knowledge Base (Markdown, agent-readable)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/knowledge-base"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Generic skill name with no reference to any third-party brand to impersonate; consistent with the Desktop Commander plugin bundle's own identity.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\n...\n---\n# Knowledge Base (Markdown, agent-readable)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/knowledge-base"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":6,"not_applicable":2},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/claude/skills/obsidian-vault","component_path":"plugins/claude/skills/obsidian-vault","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"SKILL.md declares an Obsidian vault-organizing skill (MOCs, wikilinks, frontmatter, dashboards). Body content (wikilinks, MOCs, frontmatter rules, dashboards, orphan detection, folder organization, dedup/rename workflows) matches the declared scope exactly, using only read_file/write_file/edit_block/start_search file operations on vault content -- no network egress, no credential reads, no undeclared exec capability.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"plugins/claude/skills/obsidian-vault/SKILL.md","start_line":5,"end_line":12,"snippet":"description: >-\n  Organize Obsidian vaults with MOCs, wikilinks, frontmatter/properties,\n  dashboards, orphan-note checks, and cleanup workflows. Use ONLY when the user\n  user explicitly refers to Obsidian...\n  an Obsidian-specific feature such as wikilinks (\"[[ ]]\"), the Dataview or Bases\n  plugins, or Obsidian Properties. Within that Obsidian context it covers\n  creating Maps of Content, adding/fixing wikilinks, finding orphan notes,\n  normalizing YAML frontmatter, generating Dataview/Bases dashboards, organizing"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/obsidian-vault"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"No imperative prose suppresses confirmation, hides privileged actions, or asserts override authority over the host/user. Destructive-adjacent actions (deletes during dedup) are described as 'copy unique content... then repoint before deleting it' as part of the declared workflow, not concealed from the user; the skill instead defers renames/moves to the user acting inside Obsidian itself.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/obsidian-vault/SKILL.md","start_line":169,"end_line":179,"snippet":"## Deduplicate & rename\n\n- **Duplicates**: `start_search` titles/aliases to find near-identical notes;\n  merge into one, keep the most-linked filename, copy unique content with\n  `edit_block`, then `start_search` for inbound `[[links]]` to the discarded note\n  and repoint them before deleting it.\n- **Renames**: do them inside Obsidian (Rename note / `F2`) so backlinks update —"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/obsidian-vault"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"No instruction text reads host credential stores or model conversation/system-prompt context; the skill only reads/writes vault note files. No harvest step exists, so no harvest+exfil pair is constructible.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/obsidian-vault/SKILL.md","start_line":5,"end_line":7,"snippet":"description: >-\n  Organize Obsidian vaults with MOCs, wikilinks, frontmatter/properties,\n  dashboards, orphan-note checks, and cleanup workflows..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/obsidian-vault"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"This component ships only a single SKILL.md -- no bundled scripts, hooks, dotenv, or env assignments. No format-native execution/control-plane primitive exists to evaluate.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/obsidian-vault"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"No allowed-tools/scope/permissions field in frontmatter, no wildcard grant, no bundled executable or install hook. The described capability (reading/editing local vault note files) matches the stated purpose exactly with no requested reach into shell/network/credential territory.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks, frontmatter/properties,\n  dashboards, orphan-note checks, and cleanup workflows..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/obsidian-vault"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint scan found no concealment codepoints, forged tokens, HTML comments, or ANSI escapes. Content is purely descriptive guidance for Obsidian vault organization.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":20,"snippet":"(codepoint scan: no concealment codepoints, no HTML comments, no forged chat-template tokens found in this file)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/obsidian-vault"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"SKILL.md ships fully inside the bundle; no runtime instruction fetch, no self-mutation, no launch entry owned by this component.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/obsidian-vault"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Name 'obsidian-vault' plainly describes its function and is not a near-miss/homoglyph of a brand name; the skill does not claim to BE Obsidian or impersonate the Obsidian publisher -- it explicitly scopes itself to assisting users who already use Obsidian ('Use ONLY when the user explicitly refers to Obsidian'). No trust-implying prefix, version-spoofing suffix, or fork/official claim.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks, frontmatter/properties...\n  Use ONLY when the user explicitly refers to Obsidian..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/obsidian-vault"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":6,"not_applicable":2},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/claude/skills/terminal","component_path":"plugins/claude/skills/terminal","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Terminal skill's declared purpose (shell/terminal assistance via Desktop Commander) matches actual prose content exactly. No undisclosed capability.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":1,"end_line":18,"snippet":"---\nname: terminal\ndescription: Use Desktop Commander for terminal and command-line work... Never run destructive commands without explicit confirmation.\naudience: agent"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/terminal"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"low","rationale":"No bundled hook/settings file or prose instructs the agent to bypass confirmation. All bundled skills explicitly REQUIRE confirmation before state-changing actions (terminal, ai-tools-setup, computer-health-check all gate destructive/approve-worthy actions on explicit user yes). No 'do not tell the user' / 'auto-approve' / 'proceed without confirmation' language anywhere in the bundle.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":155,"end_line":170,"snippet":"Beyond that list, treat these as **destructive -- never run without the user's\nexplicit confirmation**, even if they'd technically succeed:\n...\nFor these: show the exact command, explain in one line what it will irreversibly\ndo, and wait for a clear \"yes\"."},{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":149,"end_line":170,"snippet":"## 7. Safety: destructive commands and chaining\n\nDesktop Commander already refuses a built-in `blockedCommands` list...\n`sudo`, `mkfs`, `format`, `dd`, `fdisk`, `shutdown`, `reboot`, `diskpart`,\nexplicit confirmation**..."},{"path":"plugins/claude/skills/computer-health-check/SKILL.md","start_line":41,"end_line":46,"snippet":"- **Cleanups are opt-in.** You may *suggest* cleanups freely, but only *execute* one after\n  the user explicitly approves that specific action, and only show the exact command first."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/terminal"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"No instruction directs reading credential stores or conversation context; the skill only advises NOT to echo secrets into terminal output. No harvest step exists.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":196,"end_line":204,"snippet":"## Operating etiquette\n\n- Preview risky commands before running; run read-only inspection first.\n- Use absolute paths; respect `allowedDirectories`.\n- Never print secrets, tokens, or passwords into output; redact if they appear."}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/terminal"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Single SKILL.md, no bundled scripts/hooks/env assignments; only directs the model to call already-registered host tools.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":1,"end_line":19,"snippet":"---\nname: terminal\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Use Desktop Commander for terminal and command-line work...\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/terminal"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No allowed-tools/permissions frontmatter field declared; nothing to flag as over-broad.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":1,"end_line":19,"snippet":"---\nname: terminal\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Use Desktop Commander for terminal and command-line work...\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/terminal"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint scan found no concealment characters or forged chat-template tokens.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":1,"end_line":204,"snippet":"(full file codepoint-scanned: no concealment codepoints or forged tokens found)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/terminal"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Static, bundled SKILL.md with no launch entry, no runtime fetch, no self-update mechanism of its own.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":1,"end_line":19,"snippet":"---\nname: terminal\nversion: 0.1.0\naudience: agent\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/terminal"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Generic name 'terminal' with no brand resemblance or impersonation prose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/claude/skills/terminal/SKILL.md","start_line":1,"end_line":4,"snippet":"---\nname: terminal\nversion: 0.1.0\naudience: agent"}],"affected_tools":null,"component_name":"skills-plugins/plugins/claude/skills/terminal"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":5,"not_applicable":3},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/cursor","component_path":"plugins/cursor","metadata":{"manifest":".cursor-plugin/plugin.json"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The plugin manifest/README declare exactly what the bundle provides: an MCP server plus listed skills/rule steering the agent toward Desktop Commander tool use. No hidden components beyond what README.md enumerates were found in the directory listing.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/README.md","start_line":1,"end_line":13,"snippet":"# Desktop Commander Cursor Plugin\n...\n## Components\n- MCP server: `desktop-commander`, installed with `npx -y @wonderwhy-er/desktop-commander@latest`.\n- Rule: `rules/desktop-commander-default.mdc` ...\n- Skill: `desktop-commander-overview` ...\n- Skill: `terminal` ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The always-on rule only steers tool preference ('prefer Desktop Commander MCP ... for local filesystem, terminal, process, search'); it contains no language suppressing confirmation or bypassing guardrails, and points to the (confirmation-reinforcing) overview skill for detailed guidance.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/rules/desktop-commander-default.mdc","start_line":1,"end_line":7,"snippet":"---\ndescription: Prefer Desktop Commander MCP for local filesystem, terminal, process, search, and workspace operations.\nalwaysApply: true\n---\nFor local filesystem, terminal, process, search, and workspace operations, prefer Desktop Commander MCP, especially for persistent shells, long-running processes, file reads, structured documents, large local data files, SSH workflows, and process inspection."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The bundled overview skill reads recent tool-call context and the setup skill reads configured credentials/config values as part of their declared functionality. Both handle this data in-session: credentials are redacted and sent only to the requested provider, with no separate collection destination or exfiltration sink identified across the six bundled skills and their references.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/README.md","start_line":1,"end_line":20,"snippet":"# Desktop Commander Cursor Plugin\nDesktop Commander gives Cursor agents access to a local MCP server for terminal sessions, filesystem work, structured documents, search, process management, and SSH workflows ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The plugin's declared MCP server launch entry is an ordinary declared server launch excluded from this rule's scope. The remaining bundled files are Markdown prose (the always-on rule, skill guidance, and references) containing no executable hooks, hostile environment assignments, or covert host-control directives; the unpinned @latest launch concern is addressed instead under the package-provenance/rug-pull rule.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/rules/desktop-commander-default.mdc","start_line":1,"end_line":7,"snippet":"---\ndescription: Prefer Desktop Commander MCP for local filesystem, terminal, process, search, and workspace operations.\nalwaysApply: true\n---\nFor local filesystem, terminal, process, search, and workspace operations, prefer Desktop Commander MCP ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"plugin.json declares only the mcpServers entry, skills path, and rules path needed to deliver the bundle's stated purpose; no additional OS-level permissions, broad host access declarations, or unrelated capability grants are present in the manifest.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/.cursor-plugin/plugin.json","start_line":33,"end_line":40,"snippet":"\"skills\": \"./skills/\",\n  \"rules\": \"./rules/\",\n  \"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No hidden unicode control characters or concealed directives found in plugin.json, README.md, or the rule .mdc file.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/rules/desktop-commander-default.mdc","start_line":1,"end_line":7,"snippet":"---\ndescription: Prefer Desktop Commander MCP for local filesystem, terminal, process, search, and workspace operations.\nalwaysApply: true\n---\nFor local filesystem, terminal, process, search, and workspace operations, prefer Desktop Commander MCP ..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The plugin's own shipped launch entry starts the bundled MCP server via `npx -y @wonderwhy-er/desktop-commander@latest` — no version pin, so each launch can silently run different published code than whatever version the user originally reviewed/approved when installing the plugin. This is flagged by the rule text itself: 'a server or tool whose shipped launch entry starts from an unpinned package (@latest ... via npx) ... each start can run different code than the user approved.'","vulnerability_details":"A compromised, backdoored, or simply behavior-changing new release published to npm under @wonderwhy-er/desktop-commander would be fetched and executed automatically the next time the Cursor plugin's MCP server starts, with no re-approval step and no version/hash pinning in the manifest.","fix_guidance":"Pin mcpServers.desktop-commander.args to an exact version (e.g. @wonderwhy-er/desktop-commander@0.3.0) and bump it deliberately as part of a reviewed plugin update, rather than floating to @latest on every launch.","why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-P0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":true,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"plugins/cursor/.cursor-plugin/plugin.json","start_line":35,"end_line":40,"snippet":"\"mcpServers\": {\n    \"desktop-commander\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@wonderwhy-er/desktop-commander@latest\"]\n    }\n  }"},{"path":"plugins/cursor/README.md","start_line":7,"end_line":7,"snippet":"- MCP server: `desktop-commander`, installed with `npx -y @wonderwhy-er/desktop-commander@latest`."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"plugin.json publisher/author/homepage/repository fields ('Desktop Commander', desktopcommander.app, github.com/wonderwhy-er/DesktopCommanderMCP) match the upstream project exactly and consistently across .claude-plugin, .cursor-plugin, and package.json; no name confusable with a different, unrelated popular project was found.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/.cursor-plugin/plugin.json","start_line":1,"end_line":13,"snippet":"{\n  \"name\": \"desktop-commander\",\n  \"displayName\": \"Desktop Commander\",\n  \"version\": \"0.1.0\",\n  ...\n  \"author\": {\n    \"name\": \"Desktop Commander\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"publisher\": \"Desktop Commander\",\n  \"homepage\": \"https://desktopcommander.app\",\n  \"repository\": \"https://github.com/wonderwhy-er/DesktopCommanderMCP\","}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":1,"secure":6,"not_applicable":1},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":89,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":85,"high":93},"factors":["ATR-P0007 HIGH"]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup","component_path":"plugins/cursor/skills/ai-tools-setup","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"SKILL.md declares a skill to install/configure/validate Claude Desktop, MCP servers, OpenClaw, and Hermes using Desktop Commander's file/terminal tools. The body and the three reference files (claude-desktop-mcp.md, openclaw.md, hermes.md) describe exactly that: reading/editing config files, starting local processes, verifying installs. All network calls (npm install of named third-party packages, curl install scripts documented upstream) are disclosed, functionally tied to the stated install/configure purpose, and no credential exfiltration or hidden exec capability is present -- the skill explicitly instructs never to echo secrets.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"plugins/cursor/skills/ai-tools-setup/SKILL.md","start_line":5,"end_line":12,"snippet":"description: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers using\n  Desktop Commander... Use when a user wants to install, configure, connect,\n  getting MCP servers to connect, and diagnosing local AI tooling. Use when a\n  user wants to install, configure, connect, validate, or fix Claude Desktop, an\n  MCP server, an MCP config file, or a local AI agent/gateway. Trigger on the\n  symptoms that actually bring people in: \"Claude can't see my tools\", \"my MCP\n  server isn't showing up\", \"tools aren't loading in Claude\", \"add an MCP"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Body includes explicit transparency/disclosure rules rather than suppression: 'Translate errors, don't dump them' (formatting, not concealment of a privileged action), 'Never echo secrets... Redact secrets in any output, logs, or summaries you produce' (protects secrets from leaking, does not hide a state-changing action from the user), and 'Verify, then stop... Don't declare success on a guess.' No directive tells the agent to bypass confirmation or hide an action from the user.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/ai-tools-setup/SKILL.md","start_line":36,"end_line":54,"snippet":"3. **Translate errors, don't dump them.** When a tool returns a raw JSON/HTTP\n   error, explain in one line what it means...\n   missing, deprecated, rate-limited, or doesn't support tool-calling, suggest a\n   config or .env file* — never repeated back in chat...\n5. **Back up before you edit.** Copy the config to a .bak first...\n6. **Verify, then stop.** After a change, actually confirm it worked..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Credential handling here is applicable, not out of scope: the skill directs use of a user's already-configured provider API key for a single same-provider validation call, with explicit instructions never to echo or log the secret. The validation call and the key stay within the same provider relationship, so there is no third-party harvest-and-exfiltration pair, and the handling practices -- redaction and same-provider routing -- qualify as safe use of the credential.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/ai-tools-setup/SKILL.md","start_line":31,"end_line":35,"snippet":"2. **Verify the model/API key works before you build.** The most common reason\n   these sessions dead-end is a provider auth or billing error discovered\n   *after* a lot of setup work. Make one tiny test call with `start_process` (a quick\n   curl to the provider) early, and tell the user plainly if the key is invalid\n   or out of credit."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"This component ships only SKILL.md plus three reference markdown files (install documentation) -- no lifecycle hooks, entry-point code, dotenv/git-hooks, or exec-enabling / endpoint-substituting env assignments shipped by the skill itself. The documented third-party install commands (npm install -g openclaw, curl install.sh | bash) are install instructions for SEPARATE external tools the user runs manually, not a format-native primitive this artifact itself ships or auto-executes.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: ai-tools-setup\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"No allowed-tools/scope/permissions field, no wildcard, no bundled executable or install-lifecycle hook. The skill's use of shell/process tools (start_process for curl test calls, config edits) is intrinsic and proportionate to its declared purpose of installing/configuring/repairing local AI tooling.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: ai-tools-setup\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint scan of SKILL.md and its three reference files found no concealment codepoints, forged chat-template tokens, HTML comments, or ANSI escapes. All content is descriptive setup/troubleshooting guidance.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":20,"snippet":"(codepoint scan: no concealment codepoints, no forged chat-template tokens, no HTML comments found in this file)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"SKILL.md and its reference files ship fully inside the bundle; no runtime fetch of the skill's own instructions, no self-mutation, no launch entry owned by this component.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: ai-tools-setup\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Name 'ai-tools-setup' is generic/descriptive, not a near-miss of any specific brand. Reference docs name real third-party tools (Claude Desktop, OpenClaw, Hermes) accurately and link to their genuine official docs (docs.openclaw.ai, hermes-agent.nousresearch.com) rather than impersonating them or pointing at look-alike domains; install commands target the real published packages (npm install -g openclaw, the official Hermes installer URL).","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/ai-tools-setup/SKILL.md","start_line":68,"end_line":77,"snippet":"- **OpenClaw** — a self-hosted multi-channel gateway for AI agents...\n  → Read `references/openclaw.md`\n- **Hermes** — disambiguate first: **Hermes Agent** (the Nous Research\n  self-improving agent framework)..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/ai-tools-setup"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":7,"not_applicable":1},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/cursor/skills/computer-health-check","component_path":"plugins/cursor/skills/computer-health-check","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Declared purpose is a read-only health check; actual content (collection scripts in references/*.md) are exclusively read-only diagnostics (df, ps, vm_stat, pmset, etc.) with cleanup commands gated on explicit user approval. No undisclosed network egress or credential access.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/computer-health-check/SKILL.md","start_line":5,"end_line":8,"snippet":"description: >-\n  Run a comprehensive, read-only health check on the user's computer...\n  it never needs sudo and only performs cleanups the user explicitly approves.\n---"},{"path":"plugins/cursor/skills/computer-health-check/references/macos.md","start_line":9,"end_line":31,"snippet":"zsh -c '\necho \"== SYSTEM ==\"; sw_vers; echo \"uptime:\"; uptime\n...\necho \"== DONE ==\"'"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/computer-health-check"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The 'safety contract' section explicitly requires opt-in consent for every cleanup action ('only *execute* one after the user explicitly approves that specific action'). No disclosure-suppression or auto-approve language.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/computer-health-check/SKILL.md","start_line":31,"end_line":46,"snippet":"## The safety contract (read this first)\n...\n- **Cleanups are opt-in.** You may *suggest* cleanups freely, but only *execute* one after\n  the user explicitly approves that specific action, and only show the exact command first."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/computer-health-check"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The skill reads only local system metadata (disk, memory, battery, startup items), never credential stores or conversation context, and explicitly states nothing is sent off the machine. No harvest step exists.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/computer-health-check/SKILL.md","start_line":162,"end_line":163,"snippet":"- **Privacy.** This reads local system metadata only. Don't send anything off the machine;\n  the public-IP check is optional and should be labeled."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/computer-health-check"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Only SKILL.md + references/*.md reference docs; the inline shell diagnostics are run through start_process (the host's normal post-decision tool invocation), not at load time, and no lifecycle hook or env-hijack exists.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/computer-health-check/references/macos.md","start_line":5,"end_line":8,"snippet":"## Collection script (run as one batched process)\n\nRun this with `start_process` (timeout ~30s). It prints labeled sections you can parse."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/computer-health-check"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No declared allowed-tools/permissions scope in frontmatter; the skill is explicitly scoped to read-only diagnostics with opt-in cleanup, matching its stated purpose with no broader grant.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/computer-health-check/SKILL.md","start_line":1,"end_line":17,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Run a comprehensive, read-only health check...\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/computer-health-check"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint scan of SKILL.md and all 3 references/*.md found no concealment characters or forged tokens.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/computer-health-check/SKILL.md","start_line":1,"end_line":166,"snippet":"(full file + references/{macos,windows,linux}.md codepoint-scanned: no concealment codepoints or forged tokens found)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/computer-health-check"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Static, bundled SKILL.md + reference docs; no launch entry of its own (the plugins/cursor plugin manifest that carries the npx launch line is a sibling directory not part of this component's path and is outside the 7 components in scope for this scan).","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/computer-health-check/SKILL.md","start_line":1,"end_line":17,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/computer-health-check"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Generic descriptive name 'computer-health-check', no brand resemblance or impersonation claims.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/computer-health-check/SKILL.md","start_line":1,"end_line":4,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/computer-health-check"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":5,"not_applicable":3},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/cursor/skills/desktop-commander-overview","component_path":"plugins/cursor/skills/desktop-commander-overview","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":0,"not_applicable":0},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/cursor/skills/knowledge-base","component_path":"plugins/cursor/skills/knowledge-base","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"SKILL.md declares a Markdown knowledge-base creation/maintenance skill. Body content (index-first navigation, atomic notes, frontmatter schema, workflows using create_directory/write_file/edit_block/start_search) matches the declared scope exactly -- purely local file organization with no network egress, credential access, or undisclosed exec capability.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"plugins/cursor/skills/knowledge-base/SKILL.md","start_line":5,"end_line":10,"snippet":"description: >-\n  Create and maintain a Markdown knowledge base that any AI agent can read,\n  search, and update...\n  update notes, organize docs/notes for an agent or LLM to consume, build an\n  index of notes, or run a cleanup/maintenance pass on an existing MD knowledge\nagent can navigate it without a vector database..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/knowledge-base"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"No prose instructs the agent to skip confirmation, hide privileged steps, or assert authority over host/user policy. All file creates/edits are described as direct declared actions of the skill's core function (writing notes/index), not actions concealed from the user.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/knowledge-base/SKILL.md","start_line":151,"end_line":165,"snippet":"## Workflows\n\n### Create a new KB\n- **Normalize metadata**: read_multiple_files to audit properties...\n2. `write_file` `INDEX.md` with the instructions block, an empty controlled-tag\n   list, and an empty registry."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/knowledge-base"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"No instruction text reads host credential stores or model conversation/system-prompt context; the skill only reads/writes local Markdown note files with no outbound channel at all. No harvest step exists.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/knowledge-base/SKILL.md","start_line":5,"end_line":7,"snippet":"description: >-\n  Create and maintain a Markdown knowledge base that any AI agent can read,\n  search, and update..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/knowledge-base"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"This component ships only a single SKILL.md -- no bundled scripts, hooks, dotenv, or env assignments. No format-native execution/control-plane primitive exists to evaluate.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Create and maintain a Markdown knowledge base..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/knowledge-base"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"No allowed-tools/scope/permissions field, no wildcard, no bundled executable or install hook. Declared capability (local Markdown file organization) matches stated purpose exactly.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Create and maintain a Markdown knowledge base..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/knowledge-base"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"One HTML comment exists ('<!-- extend deliberately -->') but it sits inside an illustrative fenced code-block example of a controlled-tag list, is purely a human-readable annotation with no host-directed imperative, and is not concealed from the rendered view (it is a normal visible markdown comment in example text, not a raw-vs-rendered mismatch). No zero-width/bidi codepoints, forged chat-template tokens, or ANSI escapes found.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/knowledge-base/SKILL.md","start_line":126,"end_line":128,"snippet":"## Controlled tags\n`auth`, `security`, `billing`, `api`, `ops`   <!-- extend deliberately -->"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/knowledge-base"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"SKILL.md ships fully inside the bundle; no runtime instruction fetch, no self-mutation, no launch entry owned by this component.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Create and maintain a Markdown knowledge base..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/knowledge-base"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Name 'knowledge-base' is a generic common term, not a near-miss of any specific branded product. No trust-implying prefix, version-spoofing suffix, or impersonation prose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/knowledge-base/SKILL.md","start_line":1,"end_line":4,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/knowledge-base"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":6,"not_applicable":2},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault","component_path":"plugins/cursor/skills/obsidian-vault","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Declared purpose (Obsidian vault organization) matches actual prose content exactly; only file read/edit ops via Desktop Commander's already-declared tools, scoped to the user's vault.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/obsidian-vault/SKILL.md","start_line":5,"end_line":8,"snippet":"description: >-\n  Organize Obsidian vaults with MOCs, wikilinks, frontmatter/properties,\n  dashboards, orphan-note checks, and cleanup workflows. Use ONLY when the user\n  explicitly refers to Obsidian..."}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"low","rationale":"The skill's workflow includes state-changing edits and deduplication of vault notes, not merely passive organizational guidance. It remains SECURE because no instruction suppresses confirmation, hides changes, or directs the host to bypass approval before acting.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":209,"snippet":"(full file reviewed: no imperative directs the host to skip confirmation, hide an action from the user, or assert authority over host instructions)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"low","rationale":"The skill only reads/edits the user's Obsidian vault notes; no credential store or conversation-context read, no outbound channel. No harvest step exists.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":209,"snippet":"(full file reviewed: no credential-store or conversation-context read instruction anywhere)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Single SKILL.md with no bundled executable, hook, or env assignment.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks...\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No declared allowed-tools/permissions scope; narrow documented purpose (Obsidian vault organization).","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks...\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint scan found no concealment characters or forged tokens.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":209,"snippet":"(full file codepoint-scanned: no concealment codepoints or forged tokens found)"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Static, bundled SKILL.md; no launch entry of its own.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Name 'obsidian-vault' legitimately refers to the real Obsidian product as a compatibility descriptor (a vault-organizing helper that triggers only when the user explicitly says 'Obsidian'), not a claim to BE Obsidian or an official Obsidian extension -- no impersonation prose, no trust-implying prefix, no version-spoofing suffix, and no install-instruction that replaces or spoofs the real Obsidian distribution channel.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"plugins/cursor/skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks, frontmatter/properties,\n  dashboards, orphan-note checks, and cleanup workflows. Use ONLY when the user\n  explicitly refers to Obsidian...\n---"}],"affected_tools":null,"component_name":"skills-plugins/plugins/cursor/skills/obsidian-vault"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":5,"not_applicable":3},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/plugins/cursor/skills/terminal","component_path":"plugins/cursor/skills/terminal","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":0,"not_applicable":0},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/skills/ai-tools-setup","component_path":"skills/ai-tools-setup","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Byte-identical in substance to plugins/cursor/skills/ai-tools-setup (only a markdown link-syntax difference). Same analysis applies: declared install/configure-AI-tooling purpose matches actual instruction content with no undisclosed network/credential/exec capability.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"skills/ai-tools-setup/SKILL.md","start_line":5,"end_line":8,"snippet":"description: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers using\n  Desktop Commander — inspecting and fixing mcp.json / claude_desktop_config.json,\n  getting MCP servers to connect, and diagnosing local AI tooling."}],"affected_tools":null,"component_name":"skills-plugins/skills/ai-tools-setup"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Same content as plugins/cursor/skills/ai-tools-setup (only markdown link-syntax differs); same analysis: no consent-suppression or oversight-override prose present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/ai-tools-setup/SKILL.md","start_line":40,"end_line":48,"snippet":"4. **Never echo secrets.** API keys, tokens, and passwords go *into the right\n   config or `.env` file* — never repeated back in chat. If a user pastes a live\n   live secret, warn them, use it, and suggest they rotate it...\n5. **Back up before you edit.** Copy the config to a `.bak` first..."}],"affected_tools":null,"component_name":"skills-plugins/skills/ai-tools-setup"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"low","rationale":"The skill instructs a brief provider test call using the user's own supplied API key to confirm it works and has credit, which is a user-supplied credential read within this rule's scope. No credential-store harvesting occurs and no third-party exfiltration channel is instructed; the key is used only within the user's own existing provider relationship.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/ai-tools-setup/SKILL.md","start_line":31,"end_line":35,"snippet":"2. **Verify the model/API key works before you build.** ... Make one tiny test\n   call with `start_process` (a quick curl to the provider) early, and tell\n   the user plainly if the key is invalid or out of credit."}],"affected_tools":null,"component_name":"skills-plugins/skills/ai-tools-setup"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Same as plugins/cursor/skills/ai-tools-setup: no bundled scripts/hooks/dotenv/env assignments shipped by this skill; documented third-party install commands are manual instructions, not an auto-executed primitive.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: ai-tools-setup\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers..."}],"affected_tools":null,"component_name":"skills-plugins/skills/ai-tools-setup"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"Same manifest/content as plugins/cursor/skills/ai-tools-setup; no wildcard/scope grant, no bundled executable or install hook; capability matches declared purpose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: ai-tools-setup\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers..."}],"affected_tools":null,"component_name":"skills-plugins/skills/ai-tools-setup"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Same content as plugins/cursor/skills/ai-tools-setup; codepoint scan found no concealment codepoints, forged tokens, HTML comments, or ANSI escapes.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":20,"snippet":"(codepoint scan: no concealment codepoints, no forged chat-template tokens, no HTML comments found in this file)"}],"affected_tools":null,"component_name":"skills-plugins/skills/ai-tools-setup"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Same as plugins/cursor/skills/ai-tools-setup: ships fully inside the bundle, no runtime fetch, no self-mutation, no launch entry owned by this component.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: ai-tools-setup\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Set up, connect, validate, and repair Claude Desktop and MCP servers..."}],"affected_tools":null,"component_name":"skills-plugins/skills/ai-tools-setup"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Identical to plugins/cursor/skills/ai-tools-setup: generic name, accurate references to real third-party tools with genuine official links/install commands, no impersonation.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/ai-tools-setup/SKILL.md","start_line":1,"end_line":4,"snippet":"---\nname: ai-tools-setup\nversion: 0.1.0\naudience: agent"}],"affected_tools":null,"component_name":"skills-plugins/skills/ai-tools-setup"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":7,"not_applicable":1},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/skills/computer-health-check","component_path":"skills/computer-health-check","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Identical content to plugins/cursor and plugins/claude variants -- read-only diagnostics matching declared purpose, cleanup gated on consent.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/computer-health-check/SKILL.md","start_line":5,"end_line":8,"snippet":"description: >-\n  Run a comprehensive, read-only health check on the user's computer...\n  it never needs sudo and only performs cleanups the user explicitly approves.\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/computer-health-check"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Same safety contract as plugins/cursor variant: explicit opt-in approval required for every cleanup action.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/computer-health-check/SKILL.md","start_line":31,"end_line":46,"snippet":"## The safety contract (read this first)\n...\n- **Cleanups are opt-in.** You may *suggest* cleanups freely, but only *execute* one after\n  the user explicitly approves that specific action..."}],"affected_tools":null,"component_name":"skills-plugins/skills/computer-health-check"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Identical content to the plugins/cursor variant: reads only local system metadata, explicitly no off-machine sends. No harvest step exists.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/computer-health-check/SKILL.md","start_line":162,"end_line":163,"snippet":"- **Privacy.** This reads local system metadata only. Don't send anything off the machine;\n  the public-IP check is optional and should be labeled."}],"affected_tools":null,"component_name":"skills-plugins/skills/computer-health-check"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Same as the plugins/cursor variant: diagnostics run via start_process (post-decision tool call), no lifecycle hook/env-hijack present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/computer-health-check/references/macos.md","start_line":5,"end_line":8,"snippet":"## Collection script (run as one batched process)\n\nRun this with `start_process` (timeout ~30s). It prints labeled sections you can parse."}],"affected_tools":null,"component_name":"skills-plugins/skills/computer-health-check"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No declared allowed-tools/permissions scope; scoped to read-only diagnostics matching purpose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/computer-health-check/SKILL.md","start_line":1,"end_line":17,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Run a comprehensive, read-only health check...\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/computer-health-check"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint scan of SKILL.md and all 3 references/*.md found no concealment characters or forged tokens.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/computer-health-check/SKILL.md","start_line":1,"end_line":166,"snippet":"(full file + references/{macos,windows,linux}.md codepoint-scanned: no concealment codepoints or forged tokens found)"}],"affected_tools":null,"component_name":"skills-plugins/skills/computer-health-check"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"NOT_APPLICABLE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Static, bundled SKILL.md + reference docs at repo root skills/ directory; no launch entry, no runtime fetch.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/computer-health-check/SKILL.md","start_line":1,"end_line":17,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/computer-health-check"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Generic descriptive name, no brand resemblance.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/computer-health-check/SKILL.md","start_line":1,"end_line":4,"snippet":"---\nname: computer-health-check\nversion: 0.1.0\naudience: agent"}],"affected_tools":null,"component_name":"skills-plugins/skills/computer-health-check"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":5,"not_applicable":3},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/skills/desktop-commander-overview","component_path":"skills/desktop-commander-overview","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Body content (workflow examples, tool inventory, conventions) is a straightforward elaboration of the declared description; no capability outside the stated MCP tool-usage guidance.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":10,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities ...\n---\n# Desktop Commander MCP\nDesktop Commander gives the agent reach across the user's actual computer ...","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/skills/desktop-commander-overview"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The workflow examples repeatedly instruct the agent to seek confirmation before risky actions ('Before running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial', 'Before deploys, restarts, migrations... summarize the action and confirm with the user', 'Preview the move plan before executing destructive ops'). This reinforces human-in-the-loop gates rather than weakening, skipping, or socially engineering around them. No instruction anywhere tells the agent to proceed without confirmation, hide an action from the user, or bypass a host guardrail.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n\n`start_process(\"ssh [REDACTED]\", timeout_ms=...)` opens a long-lived SSH session ...\n\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/skills/desktop-commander-overview"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"SKILL.md lines 82-84 use get_recent_tool_calls to retrieve recent tool-call arguments and outputs, so this rule is applicable to that behavior. The retrieved history remains in-session and no instruction pairs it with an outbound channel such as SSH or another network sink to export credentials or conversation context, so no exfiltration occurs.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":98,"snippet":"## Core tool inventory\n- **Process / shell:** `start_process`, `interact_with_process`, `read_process_output`, `list_processes`, `list_sessions`, `kill_process`, `force_terminate`\n- **Files (read/write):** `read_file`, `read_multiple_files`, `write_file`, `edit_block`, `write_pdf`","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/skills/desktop-commander-overview"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"This skill ships only a SKILL.md file; its persistent shell sessions are ordinary composed tool calls, not format-native out-of-band execution, entry-point hooks, or durable control-plane persistence mechanisms, so the subject matter of this rule is not present here.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":32,"snippet":"## Example workflows\nEach example names the actual tool sequence. ...","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/skills/desktop-commander-overview"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The SKILL.md frontmatter has no capability/permission manifest fields (name/description/version/audience only) — there is nothing to over-declare. The body's described capability breadth (filesystem, process, search) matches exactly the MCP tools it documents and is gated by the MCP server's own allowedDirectories/blockedCommands configuration, not by anything this skill grants itself.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":6,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities ...\nversion: 0.1.0\naudience: agent\n---","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/skills/desktop-commander-overview"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Programmatic scan of the full file for zero-width/bidi control characters (U+200B, U+200C, U+200D, U+202A, U+202E, U+2066, U+2069, U+FEFF) found none. Manual read of the full text found only plain-language workflow guidance, no hidden directives, ANSI escapes, or 'ignore previous instructions'-style covert text.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":6,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities — persistent shells and REPLs, long-running processes, filesystem beyond the workspace, structured files (.xlsx, .docx, .pdf, images) and large local data files such as CSVs, ripgrep search at scale, SSH, or cross-turn state.\nversion: 0.1.0\naudience: agent\n---","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/skills/desktop-commander-overview"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"This skill directory ships only a SKILL.md with no launch configuration or package-install instructions of its own; it references the MCP's tools by name only. The repo-level unpinned-launch concern (root .mcp.json) is evaluated separately against the mcp-server and plugins/cursor components that actually ship that manifest.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":98,"snippet":"## Core tool inventory\nGrouped index of the tools an agent reaches for most often. ...","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/skills/desktop-commander-overview"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Skill name/branding ('desktop-commander-overview') matches the project's own product name consistently with package.json (@wonderwhy-er/desktop-commander), the repo README, and all plugin manifests (publisher 'Desktop Commander', homepage desktopcommander.app, repo wonderwhy-er/DesktopCommanderMCP). No typosquatting of a third-party name or spoofed publisher identity.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":6,"snippet":"---\nname: desktop-commander-overview\ndescription: Use for Desktop Commander MCP capabilities ...\n---","vulnerability":{}}],"affected_tools":null,"component_name":"skills-plugins/skills/desktop-commander-overview"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":7,"not_applicable":1},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/skills/knowledge-base","component_path":"skills/knowledge-base","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Identical to plugins/cursor/skills/knowledge-base (diff -rq confirms byte-identical directory contents). Declared knowledge-base maintenance purpose matches actual content with no undisclosed capability.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"skills/knowledge-base/SKILL.md","start_line":5,"end_line":7,"snippet":"description: >-\n  Create and maintain a Markdown knowledge base that any AI agent can read,\n  search, and update. Use when the user wants to start a knowledge base..."}],"affected_tools":null,"component_name":"skills-plugins/skills/knowledge-base"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Identical content to plugins/cursor/skills/knowledge-base; no consent-suppression prose present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/knowledge-base/SKILL.md","start_line":151,"end_line":165,"snippet":"## Workflows\n\n### Create a new KB\n- **Normalize metadata**: `read_multiple_files` to audit properties..."}],"affected_tools":null,"component_name":"skills-plugins/skills/knowledge-base"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"No instruction text reads host credential stores or conversation/system-prompt context; purely local note-file organization with no outbound channel. No harvest step exists.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/knowledge-base/SKILL.md","start_line":5,"end_line":7,"snippet":"description: >-\n  Create and maintain a Markdown knowledge base that any AI agent can read,\n  search, and update..."}],"affected_tools":null,"component_name":"skills-plugins/skills/knowledge-base"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"This component ships only a single SKILL.md -- no bundled scripts, hooks, dotenv, or env assignments. No format-native execution/control-plane primitive exists to evaluate.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Create and maintain a Markdown knowledge base..."}],"affected_tools":null,"component_name":"skills-plugins/skills/knowledge-base"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"medium","rationale":"Identical to plugins/cursor/skills/knowledge-base; no wildcard/scope grant, no bundled executable or install hook; capability matches declared purpose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Create and maintain a Markdown knowledge base..."}],"affected_tools":null,"component_name":"skills-plugins/skills/knowledge-base"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Identical to plugins/cursor/skills/knowledge-base: the one HTML comment present is a benign, visible example annotation ('<!-- extend deliberately -->') inside a fenced controlled-tag-list example, not a concealed directive. No concealment codepoints, forged tokens, or ANSI escapes found.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/knowledge-base/SKILL.md","start_line":126,"end_line":128,"snippet":"## Controlled tags\n`auth`, `security`, `billing`, `api`, `ops`   <!-- extend deliberately -->"}],"affected_tools":null,"component_name":"skills-plugins/skills/knowledge-base"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Identical to plugins/cursor/skills/knowledge-base: ships fully inside the bundle, no runtime fetch, no self-mutation, no launch entry owned by this component.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/knowledge-base/SKILL.md","start_line":1,"end_line":13,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Create and maintain a Markdown knowledge base..."}],"affected_tools":null,"component_name":"skills-plugins/skills/knowledge-base"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Identical to plugins/cursor/skills/knowledge-base: generic name, no impersonation.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/knowledge-base/SKILL.md","start_line":1,"end_line":4,"snippet":"---\nname: knowledge-base\nversion: 0.1.0\naudience: agent"}],"affected_tools":null,"component_name":"skills-plugins/skills/knowledge-base"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":6,"not_applicable":2},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/skills/obsidian-vault","component_path":"skills/obsidian-vault","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Declared purpose matches actual content -- Obsidian vault organization workflows only, no undisclosed capability.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":true,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/obsidian-vault/SKILL.md","start_line":5,"end_line":6,"snippet":"description: >-\n  Organize Obsidian vaults with MOCs, wikilinks, frontmatter/properties..."}],"affected_tools":null,"component_name":"skills-plugins/skills/obsidian-vault"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"low","rationale":"Purely organizational guidance; no confirmation flow to suppress and no authority-override prose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/obsidian-vault/SKILL.md","start_line":1,"end_line":209,"snippet":"(full file reviewed: no imperative directs the host to skip confirmation, hide an action from the user, or assert authority over host instructions)"}],"affected_tools":null,"component_name":"skills-plugins/skills/obsidian-vault"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"low","rationale":"Only reads/edits the user's Obsidian vault notes; no credential or conversation-context harvest, no outbound channel.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/obsidian-vault/SKILL.md","start_line":1,"end_line":209,"snippet":"(full file reviewed: no credential-store or conversation-context read instruction anywhere)"}],"affected_tools":null,"component_name":"skills-plugins/skills/obsidian-vault"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Single SKILL.md with no bundled executable, hook, or env assignment.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks...\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/obsidian-vault"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No declared allowed-tools/permissions scope; narrow documented purpose.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks...\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/obsidian-vault"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Codepoint scan found no concealment characters or forged tokens.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/obsidian-vault/SKILL.md","start_line":1,"end_line":209,"snippet":"(full file codepoint-scanned: no concealment codepoints or forged tokens found)"}],"affected_tools":null,"component_name":"skills-plugins/skills/obsidian-vault"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The skill consists solely of a bundled SKILL.md describing the model-facing instruction trust surface, with no live-fetch of instructions, launcher, or self-mutation capability present in the file, consistent with a declarative artifact that does not fetch or mutate its own instructions.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\nversion: 0.1.0\naudience: agent\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/obsidian-vault"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Same as plugins/cursor variant: legitimate compatibility descriptor for the real Obsidian product, no impersonation claim or spoofed install channel.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/obsidian-vault/SKILL.md","start_line":1,"end_line":16,"snippet":"---\nname: obsidian-vault\ndescription: >-\n  Organize Obsidian vaults with MOCs, wikilinks, frontmatter/properties...\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/obsidian-vault"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":6,"not_applicable":2},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}},{"artifact_version":"2.0","component":{"component_type":"skill","component_name":"skills-plugins/skills/terminal","component_path":"skills/terminal","metadata":{"manifest":"SKILL.md"}},"scan_provenance":{"scanned_at":"2026-10-22T05:25:57.657Z","resolved_ref":"51c7fbf2350d8c039707e9f26151ed792f2891c7","resolved_ver":null},"repo_context":{"repo_url":"https://github.com/wonderwhy-er/DesktopCommanderMCP","languages":["TypeScript","JavaScript"],"transports_detected":["stdio"],"deployment_artifacts":["Dockerfile",".github/workflows/release.yml",".github/workflows/codespell.yml","package.json","package-lock.json"],"provenance":{"publisher":"wonderwhy-er","publisher_verified":false,"signed_build":true,"dependency_cve_count":4,"maintenance_active":true,"declared_policy":false}},"component_findings":[{"rule_id":"ATR-P0001","rule_version":"1.0.0","rule_name":"Capability Transparency: Declared Purpose vs. Actual Behavior (Shadow Features)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Body is a terminal-usage guide consistent with the declared description; no undeclared capability (e.g. no hidden network exfil or persistence instructions).","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Install-time trust is granted from the artifact's self-description, so any bundled capability the description does not disclose -- network egress, credential reads, or subprocess execution -- runs as an unconsented backdoor with the host agent's full privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":1,"end_line":21,"snippet":"---\nname: terminal\n...\ndescription: >-\n  Use Desktop Commander for terminal and command-line work ...\n---\n# Terminal Command Assistant"}],"affected_tools":null,"component_name":"skills-plugins/skills/terminal"},{"rule_id":"ATR-P0002","rule_version":"1.0.0","rule_name":"Consent Suppression and Guardrail / Human-in-the-Loop Subversion via Trusted Instructions","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Section 7 ('Safety: destructive commands and chaining') explicitly instructs the agent to treat a long list of operations as 'destructive — never run without the user's explicit confirmation', to show the exact command and wait for a clear 'yes', and to avoid chaining a destructive step so a failure can't cascade past confirmation. No suppression language found.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Because a skill/plugin instruction body is ingested as trusted first-party context, consent- or oversight-suppressing prose inherits the host's full authority and turns a passive package into a self-authorizing backdoor that strips the human-in-the-loop control from every action it can reach.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":149,"end_line":170,"snippet":"## 7. Safety: destructive commands and chaining\n\nDesktop Commander already refuses a built-in `blockedCommands` list ...\n\nBeyond that list, treat these as **destructive — never run without the user's\nexplicit confirmation**, even if they'd technically succeed:\n...\nFor these: show the exact command, explain in one line what it will irreversibly\ndo, and wait for a clear \"yes\"."}],"affected_tools":null,"component_name":"skills-plugins/skills/terminal"},{"rule_id":"ATR-P0003","rule_version":"1.2.0","rule_name":"Host-Credential and Conversation-Context Harvesting and Exfiltration","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"The skill's secret redaction warnings and generic cloud CLI guidance do not direct or implement raw credential-store reads, conversation-context dumps, or memory routing; the environment inspection shown is limited to non-secret config, systemInfo, and HOME examples. The harvest-and-exfiltrate scope this rule targets does not apply here.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin that couples a host-credential or live-conversation read with an outbound channel turns an ostensibly benign helper into an autonomous exfiltration agent, leaking long-lived secrets, session tokens, and confidential prompt context to an attacker on every invocation across every tool the artifact serves.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":196,"end_line":203,"snippet":"## Operating etiquette\n- Preview risky commands before running; run read-only inspection first.\n- Use absolute paths; respect `allowedDirectories`.\n- Never print secrets, tokens, or passwords into output; redact if they appear."}],"affected_tools":null,"component_name":"skills-plugins/skills/terminal"},{"rule_id":"ATR-P0004","rule_version":"1.2.0","rule_name":"Format-Native Out-of-Band Execution and Host Control-Plane Persistence","finding":"NOT_APPLICABLE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Only SKILL.md is bundled, with no native loader, hook, dotenv, or persistent host-instruction write shipped by the skill itself. The curl-to-shell passage warns the agent to confirm before piping remote content into a shell rather than directing shipped out-of-band execution; script creation is user-requested and disclosed. This rule's subject does not apply to this bundle.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Format-native execution and control-plane writes bypass the model's reasoning loop, converting a single install approval into durable, self-reinjecting code execution that can MITM the host agent's own model endpoint and silently subvert every subsequent tool call.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":110,"end_line":116,"snippet":"- **curl / HTTP**: fine for inspection (`curl -i https://...`). Be careful with\n  anything that pipes a downloaded script straight into a shell\n  (`curl ... | sh`) — that's untrusted code execution; show it and confirm first."}],"affected_tools":null,"component_name":"skills-plugins/skills/terminal"},{"rule_id":"ATR-P0005","rule_version":"1.0.0","rule_name":"Over-Declared / Over-Privileged Artifact Manifest Scope","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"No permission/capability manifest field exists in frontmatter; scope described in the body (terminal command usage) matches the declared description exactly.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact that self-declares host privilege far beyond its stated function -- or hides executable reach inside a passive-looking package -- gives a single install a blast radius the operator never scoped, so any later mistake, compromise, or misconfiguration detonates across resources the artifact never needed.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":1,"end_line":19,"snippet":"---\nname: terminal\nversion: 0.1.0\naudience: agent\ndescription: >-\n  Use Desktop Commander for terminal and command-line work ...\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/terminal"},{"rule_id":"ATR-P0006","rule_version":"1.1.0","rule_name":"Injection and Concealed Directives in Skill/Plugin Manifest and Instruction Text","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Programmatic scan for hidden/bidi unicode control characters found none; no concealed directives in the visible text.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill or plugin's always-loaded manifest and instruction carriers (frontmatter/body, `description_for_model`, Gemini `FunctionDeclaration.description` / Vertex AI extension `manifest` / ADK agent `instruction`, tool docstrings, JSON-Schema descriptions) are concatenated into the host agent prompt at operator privilege, so a directive that is hidden with non-printing codepoints, disguised with a homoglyph, forged with role-control tokens, or masked from the rendered view silently commandeers model behavior toward attacker-chosen actions across every tool the artifact serves -- with no code execution required and nothing visible to a reviewer reading the rendered text.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":1,"end_line":19,"snippet":"---\nname: terminal\n...\ndescription: >-\n  Use Desktop Commander for terminal and command-line work ...\n---"}],"affected_tools":null,"component_name":"skills-plugins/skills/terminal"},{"rule_id":"ATR-P0007","rule_version":"1.1.0","rule_name":"Skill/Plugin Package Provenance, Integrity, and Post-Approval Mutation (Rug-Pull)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The skill mentions npm/pnpm/yarn installs in its prose, but these are ordinary user-requested terminal tasks rather than a self-owned launch entry or instruction-update mechanism. The skill ships no updater or self-mutation capability, so it remains free of rug-pull risk.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"A skill/plugin reviewed once and then trusted indefinitely becomes a rug-pull vector when its OWN model-facing instruction body, declared scope, or manifest -- or the package its launch entry runs -- can be live-swapped or silently mutated after approval -- without an approval-time digest that is re-compared on load and re-consent on any change, a publisher (or an attacker who compromises the distribution channel) can rewrite the artifact's trusted instructions or widen its tool scope under authority the host already granted, turning an approved package into an exfiltration or unauthorized-action backdoor.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":1,"end_line":19,"snippet":"---\nname: terminal\n...\n---\n# Terminal Command Assistant"}],"affected_tools":null,"component_name":"skills-plugins/skills/terminal"},{"rule_id":"ATR-P0008","rule_version":"1.0.0","rule_name":"Skill/Plugin Impersonation, Typosquatting & Publisher Spoofing","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Generic, accurately-scoped skill name ('terminal') consistent with the project's own branding; no impersonation of a third-party product.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Impersonation is the entry point of the agentic supply chain (OWASP LLM ASI04 / MITRE ATT&CK T1195): a skill or plugin installed on the strength of a familiar name inherits the host agent's execution context before any payload runs. The deception front-runs trust, so catching the identity lie breaks the chain at the cheapest point -- and it is independent of whether the current code is benign, because the same trusted-looking name can ship a payload in the next version.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/terminal/SKILL.md","start_line":1,"end_line":19,"snippet":"---\nname: terminal\n...\n---\n# Terminal Command Assistant"}],"affected_tools":null,"component_name":"skills-plugins/skills/terminal"}],"repo_findings_inherited":[{"rule_id":"ATR-C0001","rule_version":"1.2.0","rule_name":"Unrestricted Network Fetch / SSRF","finding":"VULNERABLE","severity":"HIGH","realized_severity":"MEDIUM","confidence":"high","rationale":"The read_file tool (schemas.ts ReadFileArgsSchema) accepts an arbitrary `path` + `isUrl:true` and, when isUrl is set, hands the value directly to fetch() with no scheme/host allowlist and no RFC1918/loopback/cloud-metadata (169.254.169.254) IP filtering. The tool's own description ('Can fetch content from URLs when isUrl parameter is set to true') confirms this is a declared capability, but a declared URL-fetch feature still needs SSRF containment, which is absent here. Any caller of the MCP tool (the LLM, potentially steered by injected instructions from previously-fetched/read untrusted content) can direct the server to fetch internal network addresses or cloud-metadata endpoints and have the response content returned into the agent's context.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) parses isUrl/path from the raw tool arguments and calls readFile(parsed.path, {isUrl, ...}) (src/tools/filesystem.ts:658-661), which for isUrl=true calls readFileFromUrl(url) (src/tools/filesystem.ts:441-480) -> fetch(url, {signal}) with zero host/IP validation. No code path checks for localhost/127.0.0.1/::1, RFC1918 ranges, link-local (169.254.0.0/16, i.e. cloud metadata), or restricts the scheme.","fix_guidance":"Before fetching a URL in readFileFromUrl, resolve the hostname and reject requests whose resolved IP falls in loopback/RFC1918/link-local/unique-local ranges (and reject the AWS/GCP/Azure metadata IP specifically), restrict to http/https schemes, and consider an explicit allowlist or opt-in flag the user must set for non-public URLs.","why_this_matters":"Enables internal pivoting and data exfiltration.","context_note":"[capability_by_design|relief:-60%] [readers_disagree:mitigation_present] The behaviour is within the declared contract of the tool (within_declared_contract, -40%), as assessed by both the scan and the independent adjudication. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. Realized severity MEDIUM (from HIGH: 60% context relief -- transport_gated -25%, within_declared_contract -40%, capped at -60%).","within_declared_contract":true,"transport_gated":true,"mitigation_present":false,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  sheet: z.string().optional(),\n  range: z.string().optional(),\n  options: z.record(z.any()).optional(),\n  origin: z.enum(['ui', 'llm']).optional(),\n});"},{"path":"src/handlers/filesystem-handlers.ts","start_line":74,"end_line":89,"snippet":"export async function handleReadFile(args: unknown): Promise<ServerResult> {\n    ...\n    const parsed = ReadFileArgsSchema.parse(args);\n    ...\n    const options: ReadOptions = {\n        isUrl: parsed.isUrl,\n        offset: parsed.offset ?? 0,\n        length: lengthGiven ? parsed.length : defaultLimit,\n        sheet: parsed.sheet,\n        range: parsed.range,\n        svgAsImage: parsed.origin === 'ui'\n    };\n    const resolvedFilePath = parsed.isUrl\n        ? parsed.path\n        : resolveAbsolutePath(parsed.path);\n    const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/tools/filesystem.ts","start_line":656,"end_line":661,"snippet":"export async function readFile(filePath: string, options?: ReadOptions): Promise<FileResult> {\n    const { isUrl, offset, length, sheet, range, svgAsImage } = options ?? {};\n    return isUrl\n        ? readFileFromUrl(filePath, svgAsImage)\n        : readFileFromDisk(...)"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file"],"component_name":null},{"rule_id":"ATR-C0002","rule_version":"1.3.2","rule_name":"Static Secrets","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"The set_config_value tool handler (src/tools/config.ts) emits the entire unparsed argument object to stderr before parsing, and the MCP's audit-log sink (trackToolCall) persists raw tool arguments, including commands and file contents, without redaction. Although the historical Google-Analytics keys and gitleaks hits found in earlier commits are no longer present in the current HEAD, runtime-supplied credentials passed as tool arguments can still be echoed to stderr or written to the audit log in plaintext, which this rule's scope over logged/echoed secrets also covers.","vulnerability_details":null,"fix_guidance":"Remove or redact the raw-argument stderr echo in set_config_value's handler before logging, and route all tool-argument logging (including trackToolCall's audit log) through the existing redact() secret-scrubbing function so runtime-supplied credentials are never persisted or printed in plaintext.","why_this_matters":"Plaintext secrets are easily exfiltrated.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0002 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals HIGH.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1269,"end_line":1272},{"path":"src/tools/config.ts","start_line":86,"end_line":90},{"path":"src/remote-device/remote-channel.ts","start_line":337,"end_line":339},{"path":"setup-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":19,"end_line":19,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":409,"end_line":409,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":414,"end_line":414,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":432,"end_line":432,"vulnerability":{}},{"path":"src/utils/capture.ts","start_line":445,"end_line":445,"vulnerability":{}},{"path":"track-installation.js","start_line":78,"end_line":78,"vulnerability":{}},{"path":"uninstall-claude-server.js","start_line":15,"end_line":15,"vulnerability":{}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0003","rule_version":"1.3.0","rule_name":"Runtime Command Injection via Process Execution","finding":"VULNERABLE","severity":"CRITICAL","realized_severity":"HIGH","confidence":"medium","rationale":"Desktop Commander's declared, advertised purpose is to let the LLM run arbitrary shell commands (`start_process`, `interact_with_process`) -- this is the product's core feature, so within_declared_contract=true. The repo does implement a real, non-trivial mitigation (CommandManager blocklist with chained/piped/subshell-aware parsing to resist blocklist bypass), so mitigation_present=true -- but it is a denylist of command *names*, not a sandbox or allowlist: any command name not on the configured blocklist runs with the full OS privileges of the MCP server process via a real spawned shell. This is reported as VULNERABLE per the rule's own evaluation method (a real source-to-sink process-execution path exists and the only gate is a bypassable denylist), while within_declared_contract and mitigation_present are both set true so the finding is weighted as a declared, partially-mitigated risk rather than an undisclosed defect.","vulnerability_details":"MCP tool call 'start_process' (registered in src/server.ts CallToolRequest switch, case \"start_process\" -> handlers.handleStartProcess(args)) resolves to startProcess() in src/tools/improved-process-tools.ts, which only gates the call on commandManager.validateCommand(parsed.data.command) (a command-name blocklist) and then passes the SAME raw command string straight to terminalManager.executeCommand(commandToRun, ...), which calls spawn(spawnConfig.executable, spawnConfig.args, spawnOptions) with { env: {...process.env}, shell: spawnConfig.useShellOption } -- i.e. the attacker-controlled command string reaches a real OS shell with the server process's full environment and privileges.","fix_guidance":"This is a fundamental, declared capability of the product (an agentic shell-access tool) rather than a defect to 'fix' outright; standard hardening options are: run inside a container/VM/sandbox with least-privilege OS permissions, default to a stricter allowlist mode for unattended/non-interactive use, keep the command blocklist updated and default-deny unknown commands in high-trust deployments, and ensure user-confirmation gating is enabled by default for risky operations (the project's own SKILL.md already recommends confirming high-risk commands with the user).","why_this_matters":"Unsanitized command construction in shipped runtime code can give a remote attacker arbitrary code execution with the application's privileges.","context_note":"[capability_by_design|relief:-40%|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0003 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. Realized severity HIGH (from CRITICAL: 40% context relief -- mitigation_present -40%).","within_declared_contract":true,"transport_gated":false,"mitigation_present":true,"evidence":[{"path":"src/server.ts","start_line":1328,"end_line":1331,"snippet":"// Terminal tools\n            case \"start_process\":\n                result = await handlers.handleStartProcess(args);\n                break;"},{"path":"src/tools/improved-process-tools.ts","start_line":166,"end_line":175,"snippet":"  const isAllowed = await commandManager.validateCommand(parsed.data.command);\n  if (!isAllowed) {\n    return {\n      content: [{ type: \"text\", text: `Error: Command not allowed: ${parsed.data.command}` }],\n      isError: true,\n      structuredContent: { blocked: true, command: parsed.data.command },\n    };\n  }\n\n  const commandToRun = parsed.data.command;"},{"path":"src/tools/improved-process-tools.ts","start_line":210,"end_line":216,"snippet":"  const result = await terminalManager.executeCommand(\n    commandToRun,\n    parsed.data.timeout_ms,\n    shellUsed,\n    parsed.data.verbose_timing || false,\n    maxWaitMs\n  );"},{"path":"src/terminal-manager.ts","start_line":196,"end_line":231,"snippet":"    const spawnConfig: ShellSpawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);\n    const spawnOptions: any = {\n      env: {\n        ...process.env,\n        TERM: 'xterm-256color'\n      },\n      windowsHide: true\n    };\n    if (spawnConfig.useShellOption) {\n      spawnOptions.shell = spawnConfig.useShellOption;\n    }\n    ...\n    const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);"},{"path":"src/command-manager.ts","start_line":5,"end_line":13,"snippet":"class CommandManager {\n    getBaseCommand(command: string) {\n        return command.split(' ')[0].toLowerCase().trim();\n    }\n    extractCommands(commandString: string, asTyped = false): string[] {\n        try {\n            commandString = commandString.trim();\n            const separators = [';', '&&', '||', '|', '&'];\n            const commands: string[] = [];"},{"path":"src/terminal-manager.ts","start_line":1,"end_line":8,"snippet":"import { spawn } from 'child_process';\nimport { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js';\nimport { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';\nimport { configManager, type ServerConfig } from './config-manager.js';\nimport {capture} from \"./utils/capture.js\";\nimport { analyzeProcessState, STATE_DETECTION_TAIL_CHARS, type ProcessState } from './utils/process-detection.js';\nimport { getDefaultShell, getShellSpawnArgs, type ShellSpawnConfig } from './utils/shell.js';\nimport { terminateProcessTree } from './utils/process-tree.js';"},{"path":"server.yaml","start_line":20,"end_line":20,"snippet":"StartProcessArgsSchema accepts a free-text `command: z.string()` field passed directly to terminalManager.executeCommand with no argument-vector restriction"}],"affected_tools":["start_process","interact_with_process"],"component_name":null},{"rule_id":"ATR-C0004","rule_version":"1.2.0","rule_name":"DoS Protections: Timeouts, Concurrency, Streaming Limits","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"readFileFromUrl's AbortController timeout is cleared once the fetch response headers arrive, so the URL_FETCH timeout does not bound the time spent reading the response body. The handler then calls response.text()/arrayBuffer() and buffers the entire body in memory with no size limit, so a large or slow-to-complete response from an attacker-controlled or compromised URL can consume unbounded memory or keep the read open indefinitely, even though process/terminal output elsewhere in the codebase is capped (MAX_BUFFERED_OUTPUT_CHARS, MAX_LINE_CHARS, MAX_WAIT_OUTPUT_CHARS).","vulnerability_details":null,"fix_guidance":"Keep (or re-arm) the abort timeout for the full duration of the body read, not just until response headers arrive, and enforce a maximum content-length / byte-count cap on the fetched body in readFileFromUrl, aborting the response stream once the cap is exceeded.","why_this_matters":"Resource exhaustion can crash or stall MCP.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0004 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1177,"end_line":1178,"snippet":"server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest): Promise<ServerResult> => {\n    const args = request.params.arguments;"},{"path":"src/handlers/filesystem-handlers.ts","start_line":114,"end_line":118,"snippet":"        const resolvedFilePath = parsed.isUrl\n            ? parsed.path\n            : resolveAbsolutePath(parsed.path);\n\n        const fileResult = await readFile(parsed.path, options);"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":452,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    // Set up fetch with timeout\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });\n\n        // Clear the timeout since fetch completed\n        clearTimeout(timeoutId);"},{"path":"src/tools/filesystem.ts","start_line":483,"end_line":491,"snippet":"            const buffer = await response.arrayBuffer();\n            const content = Buffer.from(buffer).toString('base64');\n\n            return { content, mimeType: contentType, metadata: { isImage } };\n        } else {\n            // For text content\n            const content = await response.text();\n\n            return { content, mimeType: contentType, metadata: { isImage } };"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0005","rule_version":"1.2.0","rule_name":"Logging Hygiene (No Secrets/PII/Tokens) & Audit Trail","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The MCP's tool-call audit logger (trackToolCall) unconditionally serializes raw tool-call arguments and appends them to the audit log (claude_tool_call.log) before any validation or redaction step runs. The remote-diagnostics redact() function sanitizes only the remote-device log and telemetry pipeline; it is never applied to this audit-logging sink. As a result, credentials or secrets passed as tool arguments (for example in shell commands or file-write contents) can be persisted in plaintext in the tool-call audit log.","vulnerability_details":null,"fix_guidance":"Apply the existing redact() function (or an equivalent secret-scrubbing routine) to tool-call arguments in trackToolCall before they are serialized and written to claude_tool_call.log, so all audit-log sinks route through the same redaction layer as remote diagnostics.","why_this_matters":"Prevents sensitive data leakage; enables forensics.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0005 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":null,"transport_gated":null,"mitigation_present":null,"evidence":[{"path":"src/server.ts","start_line":1205,"end_line":1207,"snippet":"async function handleCallToolRequest(request: CallToolRequest): Promise<ServerResult> {\n    const { name, arguments: args } = request.params;\n    const startTime = Date.now();"},{"path":"src/server.ts","start_line":1250,"end_line":1251,"snippet":"        // Track tool call\n        trackToolCall(name, args);"},{"path":"src/utils/trackTools.ts","start_line":15,"end_line":21,"snippet":"export async function trackToolCall(toolName: string, args?: unknown): Promise<void> {\n  try {\n    // Get current timestamp\n    const timestamp = new Date().toISOString();\n    \n    // Format the log entry\n    const logEntry = `${timestamp} | ${toolName.padEnd(20, ' ')}${args ? `\\t| Arguments: ${JSON.stringify(args)}` : ''}\\n`;"},{"path":"src/utils/trackTools.ts","start_line":48,"end_line":49,"snippet":"    // Append to log file (if file was renamed, this will create a new file)\n    await fs.promises.appendFile(TOOL_CALL_FILE, logEntry, 'utf8');"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0006","rule_version":"1.2.0","rule_name":"Container/Runtime Hardening","finding":"VULNERABLE","severity":"MEDIUM","realized_severity":"MEDIUM","confidence":"high","rationale":"The shipped Dockerfile (community Smithery.ai build) has no USER directive, so the container runs the Node MCP server as root by default. Trivy config scan flags this as DS-0002 (HIGH, 'Image user should not be root') and DS-0026 (LOW, no HEALTHCHECK). This increases blast radius if a code-execution bug (e.g. the terminal/exec tools, or a dependency CVE) is ever exploited inside the container.","vulnerability_details":"Dockerfile builds FROM node:lts-alpine, installs deps and copies all source, and its final CMD runs as the image default user (root) because no USER instruction is present anywhere in the file.","fix_guidance":"Add a non-root `USER node` (the official node image ships a `node` user) before CMD, and add a HEALTHCHECK instruction.","why_this_matters":"Reduces blast radius of exploits.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] within_declared_contract is ineligible for relief on ATR-C0006 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. No context relief applies; realized severity equals MEDIUM.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"Dockerfile","start_line":1,"end_line":27,"snippet":"FROM node:lts-alpine\nENV MCP_CLIENT_DOCKER=true\nWORKDIR /usr/src/app\nCOPY package*.json ./\nRUN npm install --ignore-scripts\nRUN npm rebuild @vscode/ripgrep\nCOPY . .\nRUN npm run build\nCMD [ \"node\", \"dist/index.js\" ]"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0007","rule_version":"1.3.0","rule_name":"Dependency Risk & Supply Chain","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Trivy fs scan of package-lock.json identifies markdown-it@14.3.2 (a direct production dependency, imported by the file-preview/markdown rendering pipeline) as vulnerable to CVE-2026-111758 (GHSA-gfr2-rv44-r23c, HIGH, CVSS 7.5) -- an O(n^2) DoS via unclosed link reference definitions in default parsing options, fixed in 14.3.3/15.0.3 -- and this HIGH-severity DoS path remains reachable regardless of the renderer's configuration. A second, LOW-severity CVE (CVE-2026-111766) in the same package is HTML-markup-specific; because createMarkdownIt disables raw-HTML passthrough (html:false), that second CVE does not apply to this parser's actual usage path, so only the HIGH unclosed-reference-definition issue represents a live, unaddressed risk here. The project otherwise pins dependencies via package-lock.json and avoids postinstall curl|bash patterns, but the HIGH CVE remains unaddressed in the committed lockfile.","vulnerability_details":"package-lock.json locks markdown-it to 14.3.2 (node_modules/markdown-it entry); the fix is in 14.3.3 (same major) so bumping is a drop-in, low-risk patch update, yet it has not been applied. The package is reachable at runtime via `import MarkdownIt from 'markdown-it'` in the shipped markdown parser used to render files the agent reads/previews, so feeding it a crafted unterminated-reference markdown document can hang the event loop (DoS) for whichever process parses it.","fix_guidance":"Run `npm update markdown-it` (or pin to >=14.3.3 / >=15.0.3) to remediate the HIGH unclosed-link-reference-definition DoS (CVE-2026-111758), which is reachable regardless of the html:false configuration, and commit the regenerated package-lock.json; add Trivy/`npm audit` to CI to catch future regressions.","why_this_matters":"Prevents typosquat and malicious update compromise.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0007 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"package-lock.json","start_line":8129,"end_line":8133,"snippet":"\"node_modules/markdown-it\": {\n      \"version\": \"14.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz\",\n      \"integrity\": \"sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==\","},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":1,"end_line":2,"snippet":"// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations.\nimport MarkdownIt from 'markdown-it';"},{"path":"package.json","start_line":112,"end_line":112,"snippet":"\"markdown-it\": \"^14.1.0\","},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111758","pkg_name":"markdown-it","severity":"HIGH","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}},{"path":"package-lock.json","start_line":0,"end_line":0,"snippet":null,"vulnerability":{"vulnerability_id":"CVE-2026-111766","pkg_name":"markdown-it","severity":"LOW","installed_version":"14.3.2","fixed_version":"14.3.3, 15.0.3"}}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0008","rule_version":"1.2.0","rule_name":"Missing or Inadequate Input Validation","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every MCP tool's arguments are validated through a dedicated Zod schema before use (src/tools/schemas.ts defines 26 distinct *ArgsSchema definitions, e.g. ReadFileArgsSchema, StartProcessArgsSchema, EditBlockArgsSchema, MoveFileArgsSchema), each enforcing types, required fields, and defaults, and handlers call `<Schema>.parse(args)`/`safeParse(args)` and return a structured error response (isError:true) on failure rather than passing raw/untyped input to filesystem or process sinks.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Frontline control to prevent multiple classes of vulns.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/schemas.ts","start_line":59,"end_line":71,"snippet":"export const ReadFileArgsSchema = z.object({\n  path: z.string(),\n  isUrl: z.boolean().optional().default(false),\n  offset: z.number().optional().default(0),\n  length: z.number().optional().default(1000),\n  ...\n});"},{"path":"src/tools/improved-process-tools.ts","start_line":143,"end_line":150,"snippet":"  const parsed = StartProcessArgsSchema.safeParse(args);\n  if (!parsed.success) {\n    capture('server_start_process_failed');\n    return {\n      content: [{ type: \"text\", text: `Error: Invalid arguments for start_process: ${parsed.error}` }],\n      isError: true,\n    };\n  }"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0009","rule_version":"1.2.0","rule_name":"Insecure Deserialization","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"low","rationale":"Beyond JSON.parse, PDF front-matter parsing uses the gray-matter library, which supports pluggable per-language engines, including 'js' and 'javascript' aliases that would execute arbitrary code if enabled. safeGrayMatterOptions forcibly overrides both the 'js' and 'javascript' engine entries after any caller-supplied options are merged in, so those code-executing engines can never be reactivated by caller input. No deserialization path in the codebase -- JSON.parse, or gray-matter with its JS engines disabled -- can execute attacker-controlled data as code.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Can lead to arbitrary code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/config-manager.ts","start_line":77,"end_line":80,"snippet":"// JSON.parse rejects although the config is complete.\n...\n  return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0010","rule_version":"1.2.0","rule_name":"Lack of Output Encoding/Sanitization","finding":"SECURE","severity":"MEDIUM","realized_severity":null,"confidence":"medium","rationale":"The one place this MCP renders untrusted content as HTML (the file-preview UI widget, src/ui/file-preview) is deliberately hardened: raw HTML files are rendered inside a nested `sandbox=\"allow-scripts allow-forms allow-popups\"` iframe (no allow-same-origin) with the whole frame document HTML-escaped into the `srcdoc` attribute via `escapeHtml()`, so injected markup cannot break out of the attribute, and any script that does run is isolated to a null/opaque origin under the app's CSP. Markdown rendering explicitly disables markdown-it's raw-HTML passthrough (`html: false` in createMarkdownIt), so embedded HTML in a rendered markdown file is escaped, not executed.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents UI-level injection and operator compromise.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/ui/file-preview/src/components/html-renderer.ts","start_line":29,"end_line":32,"snippet":"function renderSandboxedHtmlFrame(content: string): string {\n    const palette = resolveThemeFrameStyles();\n    const frameDocument = `<!doctype html>...`;\n    return `<iframe class=\"html-rendered-frame\" title=\"Rendered HTML preview\" sandbox=\"allow-scripts allow-forms allow-popups\" referrerpolicy=\"no-referrer\" srcdoc=\"${escapeHtml(frameDocument)}\"></iframe>`;\n}"},{"path":"src/ui/file-preview/src/markdown/parser.ts","start_line":42,"end_line":47,"snippet":"export function createMarkdownIt(options: {\n    highlight?: (code: string, language: string) => string;\n} = {}): MarkdownItInstance {\n    return new MarkdownItCtor({\n        html: false,"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0011","rule_version":"1.4.0","rule_name":"Path Traversal & Symlink Bypass (Filesystem Tools)","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"Every filesystem tool entry point routes through the single `validatePath()` choke point (src/tools/filesystem.ts), which resolves symlinks link-by-link (resolveRealPath, ELOOP-guarded) and enforces containment against `allowedDirectories` by real path, closing the 'allowed dir reached through a symlink' bypass. This containment only holds when `allowedDirectories` is configured to a restricted set of directories: an empty list or a root entry of `/` is an intentional configuration that permits all paths by design, not a bypass of the check itself. The PDF-rendering HTTP server used for document preview is independently gated: served PDFs are protected by a random per-render cookie and the server listens only on loopback, a separate containment layer from validatePath rather than a weakness in it. list-directory's symlink-type stat during the walk can determine whether an entry is a directory or file before the per-entry validatePath re-check runs, but it does not read or return out-of-scope file content.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Traversal exposes arbitrary host files; can lead to code execution.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/tools/filesystem.ts","start_line":226,"end_line":229,"snippet":"    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n\n  return resolveFrom(absolutePath);"},{"path":"src/tools/filesystem.ts","start_line":344,"end_line":349,"snippet":"        const expandedPath = expandHome(requestedPath);\n\n        // Convert to absolute path\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":216,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n  let linksFollowed = 0;\n  const resolveFrom = async (currentPath: string): Promise<string> => {\n    try {\n      return await fs.realpath(currentPath, { encoding: 'utf8' });\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    }\n    const parent = path.dirname(currentPath);\n    if (parent === currentPath) { return currentPath; }\n    const resolvedParent = await resolveFrom(parent);\n    const candidate = path.join(resolvedParent, path.basename(currentPath));\n    let isLink: boolean;\n    try {\n      isLink = (await fs.lstat(candidate)).isSymbolicLink();\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n      return candidate;\n    }\n    if (!isLink) { return candidate; }\n    if (++linksFollowed > MAX_LINKS_FOLLOWED) {\n      throw Object.assign(new Error(`ELOOP`), { code: 'ELOOP' });\n    }\n    const target = await fs.readlink(candidate, { encoding: 'utf8' });\n    return resolveFrom(path.resolve(resolvedParent, target));\n  };\n  return resolveFrom(absolutePath);\n}"},{"path":"src/tools/filesystem.ts","start_line":239,"end_line":246,"snippet":"async function resolveEntryPath(absolutePath: string): Promise<string> {\n    const parent = path.dirname(absolutePath);\n    if (parent === absolutePath) {\n        return resolveRealPath(absolutePath); // a root has no folder above it\n    }\n    return path.join(await resolveRealPath(parent), path.basename(absolutePath));\n}"},{"path":"src/tools/filesystem.ts","start_line":843,"end_line":848,"snippet":"        for (const entry of entriesToShow) {\n            const fullPath = path.join(currentPath, entry.name);\n            const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;\n\n            // A link (a junction on Windows) to a folder is a folder. A link whose\n            // target can't be read stays a [FILE], as every link was before."},{"path":"src/tools/filesystem.ts","start_line":952,"end_line":959,"snippet":"        for (const entry of entries) {\n            const fullPath = path.join(currentPath, entry.name);\n\n            try {\n                await validatePath(fullPath);\n\n                if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {"},{"path":"src/tools/filesystem.ts","start_line":139,"end_line":143,"snippet":"function expandHome(filepath: string): string {\n    if (filepath.startsWith('~/') || filepath === '~') {\n        return path.join(os.homedir(), filepath.slice(1));\n    }\n    return filepath;\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":270,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":262,"end_line":280,"snippet":"function isWithinAllowedDir(normalizedPathToCheck: string, allowedDir: string): boolean {\n    let normalizedAllowedDir = normalizePath(allowedDir);\n    if (normalizedAllowedDir.slice(-1) === path.sep) {\n        normalizedAllowedDir = normalizedAllowedDir.slice(0, -1);\n    }\n    if (normalizedPathToCheck === normalizedAllowedDir) {\n        return true;\n    }\n    return normalizedPathToCheck.startsWith(normalizedAllowedDir + path.sep);\n}"},{"path":"src/tools/filesystem.ts","start_line":189,"end_line":205,"snippet":"async function resolveRealPath(absolutePath: string): Promise<string> {\n    let linksFollowed = 0;\n\n    const resolveFrom = async (currentPath: string): Promise<string> => {\n        try {\n            return await fs.realpath(currentPath, { encoding: 'utf8' });\n        } catch (error) {\n            if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n    ...\n\n      throw Object.assign(new Error(`ELOOP: too many symbolic links encountered...`), { code: 'ELOOP' });\n        if (parent === currentPath) {\n            return currentPath; // a root that doesn't exist, e.g. an unmapped drive\n        }\n\n        // Resolve the parent first so links along the way are followed, and a\n        // relative link target is read against the link's real directory"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":340,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {"},{"path":"src/tools/filesystem.ts","start_line":340,"end_line":348,"snippet":"export async function validatePath(requestedPath: string, { entry = false }: { entry?: boolean } = {}): Promise<string> {\n    const validationOperation = async (): Promise<string> => {\n        const expandedPath = expandHome(requestedPath);\n        const absoluteOriginal = path.isAbsolute(expandedPath)\n            ? path.resolve(expandedPath)\n            : path.resolve(process.cwd(), expandedPath);\n        addToolCallPaths(requestedPath, expandedPath, absoluteOriginal);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0012","rule_version":"1.2.0","rule_name":"Denial of Wallet / Cost Controls","finding":"NOT_APPLICABLE","severity":"MEDIUM","realized_severity":null,"confidence":"high","rationale":"Desktop Commander has no billable external API dependencies in its tool-invocation paths: it operates on the local filesystem/OS processes, and its only outbound network calls (feature-flag fetch, anonymous telemetry POST, optional remote-device Supabase realtime channel) are free, low-frequency, operator-run endpoints with 3s timeouts and no retry storms -- not metered per-call paid services the agent's tool use could run up a bill against. This matches the rule's own false_positive_guard: 'No billable external dependencies.'","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"Prevents cost-based disruption.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"src/utils/feature-flags.ts","start_line":166,"end_line":169,"snippet":"const FETCH_TIMEOUT_MS = 3000;\nconst controller = new AbortController();\nconst abortTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);"}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-C0013","rule_version":"1.1.0","rule_name":"Tool Output Sanitization & Injection Containment","finding":"VULNERABLE","severity":"HIGH","realized_severity":"HIGH","confidence":"high","rationale":"Across every content-returning tool handler (read_file including isUrl=true fetched web content, read_process_output terminal/REPL output, start_search / get_more_search_results results, read_multiple_files), the MCP returns raw external text straight into the content:[{type:'text', text:<raw>}] array with no structural envelope, no stripping/escaping of control characters, ANSI escape sequences, zero-width/bidi Unicode, or fake role/system markers, and no untrustedReturn-equivalent annotation in the tool schemas/manifest. The repository supplies MCP protocol-typed results to a calling host, not a host LLM context-concatenation implementation itself, so this finding establishes that content returned by this MCP lacks any sanitization or untrusted-content signal before leaving the server -- it does not establish how, or whether, any particular downstream host concatenates that content into a model prompt.","vulnerability_details":"handleReadFile (src/handlers/filesystem-handlers.ts) builds `{ content: [{ type: 'text', text: textContent }] }` directly from `fileResult.content`, which for isUrl=true is the raw bytes/text returned by `fetch(url)` in readFileFromUrl -- no sanitize/escape/envelope step runs between the network response and the MCP response delivered to the model.","fix_guidance":"Wrap every tool's model-facing text payload in a host-enforced structural envelope (e.g. <tool_output name=\"read_file\" src=\"...\">...</tool_output>), strip/neutralize ANSI escapes, zero-width/bidi Unicode, and fake role markers before returning content, and add an untrustedReturn:true (or equivalent) annotation to read_file (especially isUrl mode), start_search, and read_process_output so that any host consuming this MCP's output can apply stricter downstream handling.","why_this_matters":"Tool outputs are a primary prompt-injection vector. Third-party content (web pages, files, API responses) may contain text crafted to override instructions or impersonate system/tool messages. Without a host-enforced envelope, sanitization, and an explicit untrusted-return signal, the model can be coerced into executing attacker-controlled directives, leaking secrets, or chaining further tool calls on behalf of the adversary.","context_note":"[capability_by_design|relief:none|ineligible:within_declared_contract] [readers_disagree:mitigation_present] within_declared_contract is ineligible for relief on ATR-C0013 because the rule declares that signal orthogonal to its risk (discount_ineligible_signals); the reported behaviour is within the tool's declared capability, and that does not reduce this finding's severity. The adjudication reported mitigation_present but the scan did not; relief (-40%) requires both readers, so none is applied for that signal. No context relief applies; realized severity equals HIGH.","within_declared_contract":false,"transport_gated":false,"mitigation_present":false,"evidence":[{"path":"src/handlers/filesystem-handlers.ts","start_line":205,"end_line":211,"snippet":"            let textContent = typeof fileResult.content === 'string'\n                ? fileResult.content\n                : fileResult.content.toString('utf8');\n            const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath);\n            ...\n            return {\n                content: [{ type: \"text\", text: textContent }],"},{"path":"src/tools/filesystem.ts","start_line":441,"end_line":450,"snippet":"export async function readFileFromUrl(url: string, svgAsImage = false): Promise<FileResult> {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);\n    try {\n        const response = await fetch(url, {\n            signal: controller.signal\n        });"},{"path":"src/server.ts","start_line":394,"end_line":397,"snippet":"When reading from the file system, only works within allowed directories.\nCan fetch content from URLs when isUrl parameter is set to true\n(URLs are always read in full regardless of offset/length)."}],"affected_tools":["read_file","read_process_output","start_search","get_more_search_results","read_multiple_files"],"component_name":null},{"rule_id":"ATR-C0014","rule_version":"1.2.0","rule_name":"CI/CD Workflow Script Injection","finding":"SECURE","severity":"HIGH","realized_severity":null,"confidence":"high","rationale":"The repo ships exactly two workflows. codespell.yml runs on push/pull_request with `permissions: contents: read` and invokes three mutable, version-tagged third-party actions (not pinned by commit SHA) with no shell interpolation of event data, so there is no script-injection surface even though the action references are mutable. release.yml is triggered only by tag-push (`v*`, `test-v*`) and `workflow_dispatch`, both of which require write access to the repository; every `run:` block consumes `${{ steps.*.outputs.* }}` / `${{ needs.*.outputs.* }}` or `env:`-indirected values rather than interpolating `github.event.*`/`github.head_ref` directly into a run block. The tag-format case-statement patterns (e.g. `v[0-9]*.[0-9]*.[0-9]*`) are permissive shell globs rather than strict SemVer validation, but they are used only as branch-selection matches, not re-parsed as shell code, so they do not create an injection path. No `pull_request_target`, `issue_comment`, or `repository_dispatch` triggers are present.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"CI/CD script injection can execute attacker-controlled commands with workflow tokens, repository secrets, trusted-runner access, artifact or cache authority, and release or deployment privileges.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":".github/workflows/release.yml","start_line":61,"end_line":71,"snippet":"on:\n  push:\n    tags: ['v*', 'test-v*']\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Cut a NEW release from main with this version bump'\n        required: false\n        type: choice\n        options: ['none', 'patch', 'minor', 'major']"},{"path":".github/workflows/release.yml","start_line":132,"end_line":150,"snippet":"      - name: Resolve release mode\n        id: mode\n        env:\n          INPUT_TAG: ${{ inputs.tag }}\n          INPUT_BUMP: ${{ inputs.bump }}\n          INPUT_REHEARSAL: ${{ inputs.rehearsal }}\n          EVENT: ${{ github.event_name }}\n        run: |\n          if [ \"$EVENT\" = \"push\" ]; then\n            MODE=\"tag\"; TAG=\"$GITHUB_REF_NAME\"\n          elif [ -n \"$INPUT_TAG\" ] && [ -n \"${INPUT_BUMP:-none}\" ] ..."}],"affected_tools":null,"component_name":null},{"rule_id":"ATR-R0001","rule_version":"1.3.0","rule_name":"Weaponization: Malicious-Purpose Artifact (Offensive Capability by Design)","finding":"SECURE","severity":"CRITICAL","realized_severity":null,"confidence":"high","rationale":"Desktop Commander's declared purpose (README.md, SKILL.md files, package metadata) is a general-purpose developer-productivity MCP server giving an LLM agent filesystem, terminal, document-editing and search capabilities on the user's own machine -- there is no offensive/malicious framing anywhere in its name, description, or instruction bodies, and no bundled operational attack tooling (no malware/ransomware/credential-harvester/C2/exploit-generation code or directives). The YARA 'Zeus_Metadata_Consent_Suppression' hits on skills/desktop-commander-overview/SKILL.md (and its plugin copies) were manually reviewed: the file's actual text explicitly instructs the agent to 'explain the intended action and get user confirmation when the risk is non-trivial' before production-impacting SSH/deploy commands -- i.e. it tells the agent to SEEK confirmation, the opposite of suppressing it -- and no zero-width/hidden Unicode or concealed instructions were found in the file's bytes. This is a dual-use local-system-access tool used defensively/productively, not a weapon by design.","vulnerability_details":null,"fix_guidance":null,"why_this_matters":"An artifact whose purpose is to weaponize the agent -- to produce malware, steal credentials, evade defenses, or run attacks at scale -- is dangerous regardless of how securely it is built; posture controls (auth, input validation, least privilege) cannot make a weapon safe, which is why a confirmed weaponization finding caps the trust verdict to `blocked` rather than being averaged against a good posture score. Catching malicious PURPOSE is a distinct safety control, and it must be conservative because offensive capability is dual-use: legitimate pentest, CTF, defensive, and research work must abstain (UNKNOWN), never block -- unless the artifact itself hides from the people who install, run or audit it.","context_note":null,"within_declared_contract":null,"transport_gated":false,"mitigation_present":null,"evidence":[{"path":"skills/desktop-commander-overview/SKILL.md","start_line":3,"end_line":44,"snippet":"### \"Debug this production issue\"\n\nBefore running production-impacting SSH commands, explain the intended action and get user confirmation when the risk is non-trivial.\n...\n### \"Deploy this to staging\"\n\nBefore deploys, restarts, migrations, or other environment-changing commands, summarize the action and confirm with the user unless they already explicitly asked for that exact operation.","vulnerability":{}}],"affected_tools":null,"component_name":null}],"component_tools":[],"summary":{"high_level_risks":["start_process/interact_with_process spawn a real OS shell for agent command strings with only a configurable command-name denylist (CommandManager) as a gate; default config grants unrestricted filesystem access (allowedDirectories: []) and the denylist covers ~30 names leaving arbitrary interpreters/binaries reachable -- SECURITY.md documents this as intentional but it is not a hard security boundary (ATR-C0003/ATR-M0002, CRITICAL).","Unrestricted URL fetch in the read_file tool (isUrl=true) reaches fetch() with no RFC1918/loopback/link-local/cloud-metadata IP filtering -- classic SSRF surface reachable by any MCP client or prompt-injected agent (ATR-C0001, HIGH).","No host-enforced envelope, control-character/ANSI/zero-width sanitization, or untrustedReturn annotation on tools returning externally-sourced text (read_file incl. URL fetch, read_process_output, start_search) -- prompt-injection containment gap (ATR-C0013, HIGH).","Known HIGH-severity DoS CVE (CVE-2026-111758) in pinned markdown-it@14.3.2 production dependency used by the shipped file-preview markdown renderer (ATR-C0007, HIGH).","All shipped MCP launch configurations (root .mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) launch Desktop Commander via unpinned `npx -y @wonderwhy-er/desktop-commander@latest` or with no version at all -- a post-approval rug-pull surface where a compromised npm publish silently changes what every installed client launches with no re-consent (ATR-P0007/ATR-M0001, HIGH).","The ai-tools-setup skill's bundled reference docs instruct piping a remote install script directly into bash (curl | bash) and installing an unpinned npm package (@latest) for third-party agent tooling (Hermes, OpenClaw) with no integrity verification step (ATR-P0007, HIGH).","Desktop Commander registers common unprefixed tool names (read_file, write_file, create_directory, list_directory, move_file, get_file_info) that collide exactly with the official MCP reference filesystem server's tool names with no namespace/disambiguation guard (ATR-M0006, MEDIUM).","Dockerfile has no USER directive (container runs as root) and no HEALTHCHECK when deployed via the Smithery/Docker path (ATR-C0006, MEDIUM)."],"top_remediations":["Add SSRF protections to readFileFromUrl: resolve and reject loopback/RFC1918/link-local/metadata IP ranges and restrict schemes before fetching caller-supplied URLs.","Change default allowedDirectories from '[] = full disk' to a narrow explicit default, and move start_process filtering from a denylist toward an opt-in allowlist model; gate set_config_value widening of allowedDirectories/blockedCommands behind a step the model alone cannot trigger.","Pin all shipped MCP launch configs (.mcp.json, plugins/claude/.claude-plugin/plugin.json, plugins/cursor/.cursor-plugin/plugin.json, gemini-extension.json) to an exact @wonderwhy-er/desktop-commander version or digest instead of @latest or unversioned references, and require a version bump plus re-consent on upgrade.","Wrap all model-facing tool text output in a host-enforced envelope and strip ANSI/zero-width/bidi/control characters and fake role markers; flag URL-fetching and process-output tools as untrusted-return.","Bump markdown-it to >=14.3.3 (or 15.0.3) and regenerate package-lock.json; wire Trivy/npm-audit into CI.","In the ai-tools-setup skill's reference docs, replace curl|bash and unpinned npm installs with versioned installs and a checksum/signature verification step before executing any downloaded installer.","Qualify Desktop Commander's filesystem tool names with a vendor/server prefix to avoid silent collision with the official MCP reference filesystem server when both are connected to the same client.","Add `USER node` and a HEALTHCHECK to the Dockerfile.","Consider adding explicit allowed-tools frontmatter scoping to each SKILL.md so the host's own tool-gating mechanism, not just instruction text, enforces least privilege per skill."]},"rollup":{"component_findings_count":{"vulnerable":0,"secure":6,"not_applicable":2},"repo_findings_inherited_count":{"vulnerable":8,"secure":6,"not_applicable":1},"component_tools_count":{},"worst_case_severity":"CRITICAL"},"trust_scores":{"repo_level":{"score":24,"tier":"critical","capability_band":"extreme","score_confidence":"medium","score_interval":{"low":16,"high":32},"factors":["ATR-M0001 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-P0007 HIGH","ATR-C0002 HIGH","ATR-C0004 MEDIUM","ATR-C0005 MEDIUM","ATR-C0006 MEDIUM","ATR-C0007 HIGH","ATR-C0013 HIGH"]},"component_level":{"score":100,"tier":"high","capability_band":"minimal","score_confidence":"high","score_interval":{"low":96,"high":100},"factors":[]}}}]}