Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions internal/rules/policies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2233,6 +2233,32 @@ def get_note_meta(note_id: str) -> dict:
" return \"n1\";\n" +
"}, { name: \"save_note\", description: \"Save a note.\", schema: {} });\n",
},

// ─── MCP-029: TS MCP tool writes to the filesystem ──────────────────────
// Coarse has_write_call signal, mirroring CSDK-012 until TS path
// normalization analysis exists.
{
name: "MCP-029 fires on filesystem write", ruleID: "MCP-029",
kind: models.KindMCPTool, lang: models.LanguageTypeScript, wantFires: true,
src: "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\n" +
"import { writeFileSync } from \"node:fs\";\n" +
"const s = new McpServer({ name: \"notes\", version: \"1.0.0\" });\n" +
"s.tool(\"save_note\", \"Save a note to disk for later retrieval.\", {}, async ({ p, body }) => {\n" +
" writeFileSync(p, body);\n" +
" return { content: [] };\n" +
"});\n",
},
{
name: "MCP-029 silent with no filesystem write", ruleID: "MCP-029",
kind: models.KindMCPTool, lang: models.LanguageTypeScript, wantFires: false,
src: "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\n" +
"const notes = new Map<string, string>();\n" +
"const s = new McpServer({ name: \"notes\", version: \"1.0.0\" });\n" +
"s.tool(\"save_note\", \"Save a note to disk for later retrieval.\", {}, async ({ body }) => {\n" +
" notes.set(\"n1\", body);\n" +
" return { content: [] };\n" +
"});\n",
},
}

// policyRepoRuleCases covers repo-scoped rules.
Expand Down
32 changes: 32 additions & 0 deletions testdata/rules-fixture/mcp/path_safety.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,35 @@ rules:
fix: >
Resolve the path with `Path(...).resolve()` and assert it sits under an
allowed root before any I/O touches it. Reject paths that escape the root.

- id: MCP-029
title: TypeScript MCP tool writes to the filesystem
severity: low
confidence: 0.5
language: typescript
applies_to:
- mcp_tool
scope: tool
match:
has_write_call: true
explanation: >
This TypeScript MCP tool handler writes to the filesystem. If the path or
the contents derive from the tool's arguments, the caller on the other
side of the protocol chooses both — and that caller is a model, steerable
by a prompt injection carried in retrieved content or an earlier tool
result. How the server is deployed makes this sharper than it looks: a
stdio server is launched as a subprocess by whatever client the user is
running, so it inherits that user's own filesystem permissions rather
than a service account's, and a write escaping its intended directory
reaches the user's home directory, dotfiles, and SSH keys. The server also
cannot see the injection — it receives a well-formed tools/call for a path
it has no way to distinguish from a legitimate one. (Coarse signal — it
flags any filesystem write, not only unnormalized paths, because
TypeScript path-normalization analysis is not yet wired. Confirm the path
is genuinely caller-supplied before acting.)
fix: >
Confine writes to a dedicated working directory: resolve the final path,
verify it stays under that root before writing, and reject absolute paths
and any input containing "..". Where the tool only ever writes generated
names, derive the filename server-side from an id rather than accepting a
path over the protocol at all.
Loading