@shieldfive/mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +200 -0
- package/LICENSE +201 -0
- package/README.md +365 -0
- package/SECURITY.md +144 -0
- package/package.json +49 -0
- package/src/format.mjs +157 -0
- package/src/fsops.mjs +361 -0
- package/src/limits.mjs +55 -0
- package/src/plans.mjs +188 -0
- package/src/roots.mjs +336 -0
- package/src/scan.mjs +269 -0
- package/src/server.mjs +363 -0
- package/src/tools/mutate.mjs +780 -0
- package/src/tools/read.mjs +515 -0
- package/src/trash.mjs +264 -0
package/README.md
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# @shieldfive/mcp
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an
|
|
4
|
+
AI assistant manage files on your own machine: find duplicates by content, find
|
|
5
|
+
what is large or stale, and move, rename or trash them.
|
|
6
|
+
|
|
7
|
+
It holds no ShieldFive credential, makes no network request, and does not import
|
|
8
|
+
`@shieldfive/crypto`. Those are not gaps to be filled in a later version. They
|
|
9
|
+
are the security boundary, and the section below explains what they cost you.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx @shieldfive/mcp ~/Documents ~/Downloads
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Requires Node 20 or newer.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install -g @shieldfive/mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Claude Desktop
|
|
24
|
+
|
|
25
|
+
Add to `claude_desktop_config.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"shieldfive": {
|
|
31
|
+
"command": "npx",
|
|
32
|
+
"args": ["-y", "@shieldfive/mcp", "/Users/you/Documents", "/Volumes/Archive"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every path after the package name is a **root**. The server can read and write
|
|
39
|
+
inside those directories and nowhere else. There is no default root and no
|
|
40
|
+
override flag — a server started with no roots will refuse every call and tell
|
|
41
|
+
you so.
|
|
42
|
+
|
|
43
|
+
`SHIELDFIVE_MCP_ROOTS` adds roots as well — the two are combined, not
|
|
44
|
+
alternatives — as a list separated by your platform's path separator (`:` on
|
|
45
|
+
macOS and Linux, `;` on Windows):
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
SHIELDFIVE_MCP_ROOTS="/Users/you/Documents:/Volumes/Archive" npx @shieldfive/mcp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Whitespace around a root is ignored. In a path given to a tool it is not: there,
|
|
52
|
+
every character is part of the path.
|
|
53
|
+
|
|
54
|
+
## See it work first
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm run demo
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`demo/run-demo.mjs` builds five files in a temporary directory — two with
|
|
61
|
+
identical contents under different names, a same-size decoy, a 12 MB archive and
|
|
62
|
+
a two-year-old PDF — runs the read tools over them, previews a trash call, then
|
|
63
|
+
confirms it and shows the manifest. It touches nothing outside that directory
|
|
64
|
+
and removes it at the end (`--keep` leaves it in place).
|
|
65
|
+
|
|
66
|
+
## What this cannot do
|
|
67
|
+
|
|
68
|
+
**It cannot see your ShieldFive vault.** Not the file list, not the names, not
|
|
69
|
+
the sizes. It will not tell you whether a local file is already backed up,
|
|
70
|
+
because it has no way to know and it is not permitted to guess.
|
|
71
|
+
|
|
72
|
+
That is a deliberate trade. The alternative was to authenticate with a full
|
|
73
|
+
ShieldFive account JWT — the only credential the vault API accepts. That token
|
|
74
|
+
also opens `/api/vault-key`, which returns your wrapped root key and an ML-KEM
|
|
75
|
+
public key, and every content-download route, and **none of it can be scoped
|
|
76
|
+
away**, because no scoped vault credential exists. A server holding that token
|
|
77
|
+
would be *declining* to read your files rather than being *unable* to, with the
|
|
78
|
+
difference resting on a client-side denylist and on nothing else on your machine
|
|
79
|
+
reading the token file. A server holding no token cannot read them at all.
|
|
80
|
+
|
|
81
|
+
When a scoped, metadata-only key exists, vault tools can be added behind it.
|
|
82
|
+
Until then this is a local file manager that happens to be published by the
|
|
83
|
+
people who make an encrypted vault.
|
|
84
|
+
|
|
85
|
+
**It cannot stop the results from reaching your AI provider.** This server
|
|
86
|
+
makes no network request, and that is worth exactly what it says and no more:
|
|
87
|
+
everything it returns — paths, file names, sizes, dates, the digests it
|
|
88
|
+
reports — goes back to the AI client that called it, and if that client is a
|
|
89
|
+
cloud assistant, those names travel to the assistant's provider like the rest
|
|
90
|
+
of your conversation. The server's silence is not the client's. Choose roots on
|
|
91
|
+
that basis: point it at the folders you would be willing to describe out loud,
|
|
92
|
+
and it will never see anything else.
|
|
93
|
+
|
|
94
|
+
**It will never infer that two files are the same from their names and sizes.**
|
|
95
|
+
Duplicate detection reads both files and compares a full SHA-256 of their
|
|
96
|
+
contents. Name matching is how a deduplication tool deletes the only copy of
|
|
97
|
+
something, and the cost of getting it right is a few seconds of disk I/O.
|
|
98
|
+
|
|
99
|
+
Hashing is budgeted, though, and the budget can make the answer incomplete.
|
|
100
|
+
Candidates are bucketed by size, screened on a hash of the first 64 KiB where
|
|
101
|
+
the files are bigger than that, then confirmed with a full digest. Every read of
|
|
102
|
+
either kind counts against `max_files_hashed`, 20,000 by default. When the
|
|
103
|
+
budget runs out, the rest goes unhashed — the group it runs out in is hashed in
|
|
104
|
+
part, oldest copies first — and the result says how many files and how much space
|
|
105
|
+
were never checked. Groups are processed largest-first, so what survives a tight
|
|
106
|
+
budget is what was worth the most.
|
|
107
|
+
|
|
108
|
+
What counts as reclaimable is counted per file on disk. Names that are hardlinks
|
|
109
|
+
to one file are one copy, because removing one of them frees nothing. APFS clones
|
|
110
|
+
— what Finder's Duplicate makes on an APFS volume — share their storage too, but
|
|
111
|
+
nothing this server can read tells a clone from a real copy, so clones are
|
|
112
|
+
reported as reclaimable when trashing one frees little or nothing. The copy
|
|
113
|
+
nominated to keep is the one modified earliest; a tie goes to the shorter path,
|
|
114
|
+
then to the path in code-unit order, so the same tree always nominates the same
|
|
115
|
+
copy.
|
|
116
|
+
|
|
117
|
+
**It deletes nothing of yours, with one exception.** `trash_local` *moves* files
|
|
118
|
+
into a `.shieldfive-mcp-trash` directory on the same volume they are on, and
|
|
119
|
+
writes a `manifest.json` recording where each one was. **No disk space is freed**
|
|
120
|
+
until you delete that directory yourself, in your own file manager, with your
|
|
121
|
+
own undo. The tool says so in its own output so the assistant cannot report the
|
|
122
|
+
space as reclaimed. The exception is a `move_local` between volumes, which has
|
|
123
|
+
to copy: its source is removed, but only after the copy has been verified — see
|
|
124
|
+
[Moving across volumes](#moving-across-volumes).
|
|
125
|
+
|
|
126
|
+
That holds for overwriting too. `move_local` with `overwrite: true` moves the
|
|
127
|
+
item already at the destination into the trash and then takes its place; it does
|
|
128
|
+
not remove it. The preview tells you how many files and how many bytes would be
|
|
129
|
+
displaced, not just how many are being moved. If the move then fails, the
|
|
130
|
+
displaced item is put back.
|
|
131
|
+
|
|
132
|
+
**Every tool that changes anything does nothing by default.** Call it without
|
|
133
|
+
`confirm: true` and it resolves the paths, checks containment, reports exactly
|
|
134
|
+
what it would do, and stops. The preview runs the same checks as the action, so
|
|
135
|
+
a plan that reports a refusal is a refusal.
|
|
136
|
+
|
|
137
|
+
**And a confirmed call has to be the plan you saw.** The preview returns a
|
|
138
|
+
`plan_token`; `confirm: true` without it is refused. The confirmed call plans
|
|
139
|
+
again from the filesystem as it is now, compares that plan with the one the
|
|
140
|
+
token approved — the paths, what each entry is, its size and modification time,
|
|
141
|
+
and the file and byte counts underneath it — and refuses if anything differs,
|
|
142
|
+
naming what changed. A token performs one change and expires after ten minutes.
|
|
143
|
+
So a directory that grew, a destination that appeared, or a path that now points
|
|
144
|
+
at a different file stops the call instead of silently widening it.
|
|
145
|
+
|
|
146
|
+
## Tools
|
|
147
|
+
|
|
148
|
+
| Tool | Reads | Writes |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `list_local` | files, sizes, dates | — |
|
|
151
|
+
| `find_duplicates` | file contents (SHA-256) | — |
|
|
152
|
+
| `find_large_files` | sizes | — |
|
|
153
|
+
| `find_old_files` | modification times | — |
|
|
154
|
+
| `storage_summary` | sizes, by extension and directory | — |
|
|
155
|
+
| `move_local` | sizes of both the source and anything it would displace | moves a file, folder or symlink; moves a displaced destination to the trash; between volumes, copies, verifies, then removes the source |
|
|
156
|
+
| `rename_local` | — | renames in place, never over an existing name |
|
|
157
|
+
| `create_local_folder` | — | creates a directory |
|
|
158
|
+
| `trash_local` | sizes of the subtree being trashed | moves into the trash directory on the item's own volume, writes a manifest |
|
|
159
|
+
|
|
160
|
+
Defaults, all overridable per call: `list_local` returns 200 rows, the other
|
|
161
|
+
listings 100. `find_large_files` starts at `min_bytes` 100,000,000 (100 MB).
|
|
162
|
+
`find_old_files` at `older_than_days` 365. `find_duplicates` skips empty files
|
|
163
|
+
(`min_bytes` 1), hashes at most `max_files_hashed` 20,000 of them and returns
|
|
164
|
+
100 groups, each listing at most 50 of its copies. `storage_summary` reports the
|
|
165
|
+
top 15 extensions and top 15 directories. Every scan stops at `max_files`
|
|
166
|
+
200,000 files across all roots, and walks the root and 64 levels of
|
|
167
|
+
subdirectories below it.
|
|
168
|
+
|
|
169
|
+
Every override has a ceiling, enforced by the MCP schema and again by the tool
|
|
170
|
+
itself: `limit` 10,000 rows, `max_files` 1,000,000, `max_files_hashed`
|
|
171
|
+
1,000,000, `paths` 1,000 per `trash_local` call, 4,096 characters for a path and
|
|
172
|
+
255 bytes for `new_name`. A refusal quotes only the start of a value that was
|
|
173
|
+
too long.
|
|
174
|
+
|
|
175
|
+
`find_old_files` reports modification time, which is a weak signal: some copy
|
|
176
|
+
operations reset it to the copy date, and an untouched file is not an unwanted
|
|
177
|
+
one. The tool says this in its own result rather than leaving the assistant to
|
|
178
|
+
present a shortlist as a verdict.
|
|
179
|
+
|
|
180
|
+
### The walk is not exhaustive
|
|
181
|
+
|
|
182
|
+
Every read tool walks the same way, and it skips things by default:
|
|
183
|
+
|
|
184
|
+
- **Hidden entries**, unless you pass `include_hidden: true`.
|
|
185
|
+
- **Nineteen build and cache directories** by name, wherever they appear:
|
|
186
|
+
`node_modules`, `.git`, `.svn`, `.hg`, `.cache`, `.venv`, `venv`,
|
|
187
|
+
`__pycache__`, `.next`, `.turbo`, `dist`, `build`, `target`, `Pods`,
|
|
188
|
+
`.gradle`, `.tox`, `.mypy_cache`, `.pytest_cache`, and this server's own
|
|
189
|
+
trash. `build`, `dist` and `target` are ordinary folder names outside a code
|
|
190
|
+
tree, so this can exclude real data — there is no way to override the list
|
|
191
|
+
yet.
|
|
192
|
+
- **Symlinks**, always, with no override.
|
|
193
|
+
|
|
194
|
+
All three are counted and reported in the result's warnings, so a total that
|
|
195
|
+
looks too small says why. It still means `storage_summary` is not a disk-usage
|
|
196
|
+
tool: point it at a developer's home directory and it will tell you so, but it
|
|
197
|
+
will not tell you where the space went.
|
|
198
|
+
|
|
199
|
+
When the `max_files` budget runs out before every root has been walked, the
|
|
200
|
+
result lists the roots it walked under `scanned` and the others under
|
|
201
|
+
`not_scanned`, and its warning names them.
|
|
202
|
+
|
|
203
|
+
### Emptying the trash
|
|
204
|
+
|
|
205
|
+
This server does not, and cannot. `trash_local` moves each item into
|
|
206
|
+
`.shieldfive-mcp-trash/<batch>/` in the highest directory, between the item and
|
|
207
|
+
its root, that is on the item's own volume: the root itself, unless the item is
|
|
208
|
+
on a drive mounted inside the root, and then that drive's top directory.
|
|
209
|
+
`<batch>` is a timestamp, a process id and a counter, so no two calls share one.
|
|
210
|
+
The `manifest.json` beside the items is written before any of them moves and
|
|
211
|
+
lists where each came from; an entry whose `trashed_to` does not exist was
|
|
212
|
+
planned but not moved. Removing them for real is a `rm -rf` you run yourself,
|
|
213
|
+
once you have looked at what is in there. Nothing here frees disk space on its
|
|
214
|
+
own.
|
|
215
|
+
|
|
216
|
+
A mount point cannot be trashed, because no directory on its own volume inside
|
|
217
|
+
the root can hold it. If `.shieldfive-mcp-trash` is a symlink or a file,
|
|
218
|
+
`trash_local` and an overwriting `move_local` refuse rather than follow it.
|
|
219
|
+
|
|
220
|
+
### Moving across volumes
|
|
221
|
+
|
|
222
|
+
`rename(2)` cannot cross volumes, so a move between them is a copy followed by
|
|
223
|
+
removing the source — the one place this server removes something you made. It
|
|
224
|
+
is done so that a failure at any point loses nothing:
|
|
225
|
+
|
|
226
|
+
- The copy is made under a fresh hidden name beside the destination
|
|
227
|
+
(`.shieldfive-mcp-incoming-<pid>-<n>`), created exclusively, and put in place
|
|
228
|
+
without replacing anything. Nothing that was already there is touched.
|
|
229
|
+
- A folder holding a symlink, a FIFO, a socket or a device file is refused
|
|
230
|
+
before any of it is removed, because a copy cannot carry those faithfully.
|
|
231
|
+
- Every file is flushed to disk and compared with its source — same size, same
|
|
232
|
+
SHA-256 — and the source must not have changed since it was copied. If either
|
|
233
|
+
check fails, the copy is discarded and the source stays.
|
|
234
|
+
- The source is removed file by file, each only if it is still the file that was
|
|
235
|
+
copied, and folders only once they are empty. Anything that changed or
|
|
236
|
+
appeared during the move is left where it is and listed in
|
|
237
|
+
`source_left_in_place`.
|
|
238
|
+
|
|
239
|
+
A crash in the middle can leave a partial copy under that hidden name. The
|
|
240
|
+
source is intact until its copy is in place.
|
|
241
|
+
|
|
242
|
+
### Cancellation
|
|
243
|
+
|
|
244
|
+
A cancelled request starts no change. `trash_local` stops between items, never
|
|
245
|
+
inside one, so each item is either moved and recorded or untouched, and the
|
|
246
|
+
error says which. A `move_local` cancelled before its source starts being
|
|
247
|
+
removed is undone, including putting back anything it displaced; after that
|
|
248
|
+
point it finishes, because stopping would leave half a tree on each side. The
|
|
249
|
+
MCP SDK sends no response to a cancelled request, so what a cancelled call did
|
|
250
|
+
is written to the server's stderr log and, for the trash, to the manifest.
|
|
251
|
+
|
|
252
|
+
## How containment works
|
|
253
|
+
|
|
254
|
+
Every path an assistant supplies is used exactly as given, so `"report "` is
|
|
255
|
+
never `"report"`, and resolved with `realpath` — following every symlink — before
|
|
256
|
+
anything reads it or writes through it. The result must sit inside a configured
|
|
257
|
+
root. A separator-aware boundary check means `/data/roots-evil` does not match
|
|
258
|
+
the root `/data/root`.
|
|
259
|
+
|
|
260
|
+
That ordering is the point. A string check on the supplied path is defeated by
|
|
261
|
+
`..`; a check after `path.resolve` is still defeated by a symlink, because
|
|
262
|
+
`/allowed/link -> /etc` resolves to a string under `/allowed` while reading
|
|
263
|
+
`/etc`. Resolving links first closes both, and it is why the directory walk uses
|
|
264
|
+
`lstat` and never follows a link — a link the walk traversed would be a path
|
|
265
|
+
containment never got to see.
|
|
266
|
+
|
|
267
|
+
Destinations that do not exist yet — a move target, a new folder — are checked
|
|
268
|
+
by resolving the nearest existing ancestor and re-appending the rest, so writing
|
|
269
|
+
through a symlinked parent is caught before the write rather than after it. A
|
|
270
|
+
symlink whose target does not exist is refused wherever a write would pass
|
|
271
|
+
through it: `realpath` reports it exactly like a missing path, and taking that
|
|
272
|
+
at its word would let a copy land wherever the link points.
|
|
273
|
+
|
|
274
|
+
The one thing not followed is the item a mutating tool acts on. A symlink given
|
|
275
|
+
to `move_local`, `rename_local` or `trash_local` is moved, renamed or trashed
|
|
276
|
+
itself, the way `mv` treats it, and what it points to is not touched; only the
|
|
277
|
+
link's own position has to be inside a root. The same goes for the destination
|
|
278
|
+
of a move: a symlink there, dangling or not, is an existing entry that
|
|
279
|
+
`overwrite: true` would move to the trash, not a folder to move into. Give the
|
|
280
|
+
folder's real path for that.
|
|
281
|
+
|
|
282
|
+
`rename_local` and `move_local` do not replace something that appears at the
|
|
283
|
+
destination after they have checked it. A file is hard-linked to its new name
|
|
284
|
+
and only then unlinked from the old one, a symlink is recreated, and a folder is
|
|
285
|
+
renamed over an empty placeholder made a moment before, so something appearing
|
|
286
|
+
in between makes the operation fail rather than be overwritten. Where there is
|
|
287
|
+
no such operation — FIFOs, sockets and device files, filesystems without hard
|
|
288
|
+
links such as FAT and exFAT, and folders on Windows — the tool checks and then
|
|
289
|
+
renames, and a file created in that instant would be replaced.
|
|
290
|
+
|
|
291
|
+
## What the tests assert
|
|
292
|
+
|
|
293
|
+
`npm test` runs 161 tests. The ones worth knowing about:
|
|
294
|
+
|
|
295
|
+
- A symlink pointing out of a root is refused, on both the read and the write
|
|
296
|
+
side, and so is a dangling symlink on a write path.
|
|
297
|
+
- A `.shieldfive-mcp-trash` that is a symlink out of the root is refused, and
|
|
298
|
+
nothing is written through it.
|
|
299
|
+
- Two files with the same name and the same size but different contents are
|
|
300
|
+
**not** reported as duplicates, and two names for one file are not counted as
|
|
301
|
+
space to reclaim.
|
|
302
|
+
- `trash_local` leaves the bytes readable at their new location, on the same
|
|
303
|
+
volume, and reports `space_freed_bytes: 0`.
|
|
304
|
+
- On a RAM disk mounted inside a root, a move across volumes keeps a source
|
|
305
|
+
whose copy arrives corrupt or that changes while it is copied, leaves an
|
|
306
|
+
existing file at its old staging name alone, refuses a folder holding a FIFO,
|
|
307
|
+
and never writes through a dangling symlink. These run on macOS and are
|
|
308
|
+
skipped elsewhere.
|
|
309
|
+
- A file that appears at the new name between the check and the rename is not
|
|
310
|
+
replaced.
|
|
311
|
+
- A cancelled request moves nothing, and a trash batch cancelled midway says
|
|
312
|
+
exactly what it moved.
|
|
313
|
+
- No file under `src/` imports a networking module, calls `fetch`, spawns a
|
|
314
|
+
subprocess, or reads any environment variable other than
|
|
315
|
+
`SHIELDFIVE_MCP_ROOTS`.
|
|
316
|
+
- A real MCP client over a real stdio transport sees nine tools and no vault
|
|
317
|
+
tool, including when the server is started through a symlink the way npm
|
|
318
|
+
installs it.
|
|
319
|
+
|
|
320
|
+
The network assertion has a limit worth stating: it proves nothing in `src/`
|
|
321
|
+
reaches the network. It does not prove the dependency tree is network-free —
|
|
322
|
+
`@modelcontextprotocol/sdk` ships HTTP transports for other people's servers,
|
|
323
|
+
and claiming otherwise would be false. What closes that gap is that `server.mjs`
|
|
324
|
+
imports the stdio transport and no HTTP one, which is also asserted.
|
|
325
|
+
|
|
326
|
+
## Limits
|
|
327
|
+
|
|
328
|
+
- **The plan check narrows the gap between preview and action; it does not
|
|
329
|
+
close it.** The comparison happens inside the confirmed call, so a change
|
|
330
|
+
arriving between that check and the write itself is still possible. Each tool
|
|
331
|
+
re-checks its own destination immediately before writing, which is what makes
|
|
332
|
+
that window small rather than absent, and no path-based tool can do better.
|
|
333
|
+
- **A plan binds what it named.** For a directory, that is the entry itself plus
|
|
334
|
+
the file and byte counts underneath it — enough to catch content appearing,
|
|
335
|
+
disappearing or changing size, but not a file edited in place to exactly the
|
|
336
|
+
same length within the same second.
|
|
337
|
+
- **Sizes are file-content sizes.** They exclude directory overhead and ignore
|
|
338
|
+
filesystem compression, sparse files, hardlinks and APFS clones, so totals will
|
|
339
|
+
not match a disk utility exactly; `storage_summary` counts every name of a
|
|
340
|
+
hardlinked file.
|
|
341
|
+
- **APFS clones look like copies.** `find_duplicates` reports them as
|
|
342
|
+
reclaimable, and trashing one frees little or nothing.
|
|
343
|
+
- **Scans are capped** by default at 200,000 files, and at the
|
|
344
|
+
root and 64 levels of subdirectories below it. When a cap is hit the result
|
|
345
|
+
says so, in the summary line as well as in a field: a scan that stopped at a
|
|
346
|
+
cap otherwise reads exactly like one that finished, and the assistant reports
|
|
347
|
+
a partial list as the whole of it.
|
|
348
|
+
- **Cross-volume behaviour is tested on macOS only,** against a RAM disk the
|
|
349
|
+
tests mount inside their own temporary directory.
|
|
350
|
+
- **Windows is untested.** The code uses no POSIX-only API, and path handling
|
|
351
|
+
goes through `node:path`, but nobody has run it there.
|
|
352
|
+
|
|
353
|
+
[SECURITY.md](SECURITY.md) carries the rest: time-of-check/time-of-use, the
|
|
354
|
+
windows in which a rename can still replace something, hardlinks, what
|
|
355
|
+
inheriting the environment does and does not mean, and what the no-network
|
|
356
|
+
assertion covers.
|
|
357
|
+
|
|
358
|
+
## Security
|
|
359
|
+
|
|
360
|
+
Report vulnerabilities to `security@shieldfive.com`. See
|
|
361
|
+
[SECURITY.md](SECURITY.md).
|
|
362
|
+
|
|
363
|
+
## Licence
|
|
364
|
+
|
|
365
|
+
Apache-2.0.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
`@shieldfive/mcp` runs on a user's own machine, reads and writes files in
|
|
4
|
+
directories they name, and is driven by an AI assistant. Its security properties
|
|
5
|
+
are mostly about containment and about not holding things it does not need.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
**Do not open a public GitHub issue for a security report.** Email
|
|
10
|
+
`security@shieldfive.com`.
|
|
11
|
+
|
|
12
|
+
There is no PGP key published. Encrypt with a temporary key on request, or send
|
|
13
|
+
in plaintext — a report that arrives is worth more than one that waits for key
|
|
14
|
+
exchange.
|
|
15
|
+
|
|
16
|
+
Include what you have: a description and its impact, steps to reproduce, the
|
|
17
|
+
package version and your Node version and operating system, whether the issue is
|
|
18
|
+
already public, and how you would like to be credited.
|
|
19
|
+
|
|
20
|
+
### What to expect
|
|
21
|
+
|
|
22
|
+
| Severity | Acknowledged | First substantive reply | Fix target |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| Critical — data loss, or reads/writes outside the configured roots | 1 working day | 3 working days | 7 days |
|
|
25
|
+
| High — the `confirm: true` gate failing open, or a claim in the README the code does not honour | 2 working days | 5 working days | 30 days |
|
|
26
|
+
| Medium / Low — everything else, including a result that is wrong rather than unsafe | 5 working days | 10 working days | next release |
|
|
27
|
+
|
|
28
|
+
**There is no bug bounty.** ShieldFive ran one until 2026-07; it closed, and
|
|
29
|
+
`/security/bug-bounty` redirects to the security page. Reports are answered and
|
|
30
|
+
credited on request. They are not paid.
|
|
31
|
+
|
|
32
|
+
### Safe harbour
|
|
33
|
+
|
|
34
|
+
This server runs on your machine against your own files, so there is no service
|
|
35
|
+
to degrade and nobody else's data to reach. Research against your own
|
|
36
|
+
installation is welcome and we will not pursue legal action over it. Give us 90
|
|
37
|
+
days from your first report before publishing, or less if we have already
|
|
38
|
+
shipped the fix.
|
|
39
|
+
|
|
40
|
+
## Scope
|
|
41
|
+
|
|
42
|
+
In scope: this package's own source — path containment, the walk, the mutating
|
|
43
|
+
tools, the trash, the MCP surface, and the claims its README makes.
|
|
44
|
+
|
|
45
|
+
Out of scope for this repository, with the right destination:
|
|
46
|
+
|
|
47
|
+
- The ShieldFive vault, web application and API — `security@shieldfive.com`,
|
|
48
|
+
same address, different codebase.
|
|
49
|
+
- `@shieldfive/crypto` — its own repository. **This package does not depend on
|
|
50
|
+
it**, and a test asserts no `@shieldfive/*` or `@supabase/*` package is a
|
|
51
|
+
dependency.
|
|
52
|
+
- `@modelcontextprotocol/sdk` and `zod`, this package's only two dependencies —
|
|
53
|
+
report upstream. A vulnerability in how *this* package uses them is in scope.
|
|
54
|
+
|
|
55
|
+
## Threat model
|
|
56
|
+
|
|
57
|
+
The assistant driving this server is **not** trusted to choose safe paths; that
|
|
58
|
+
is what containment is for. It is assumed not to be actively adversarial,
|
|
59
|
+
because it already runs with the user's privileges through every other tool it
|
|
60
|
+
has.
|
|
61
|
+
|
|
62
|
+
| Concern | What is done | Tested |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| A path argument escaping the allowed roots | Every path is used exactly as given — nothing is trimmed — and `realpath`-resolved before use; it must land inside a configured root, by a separator-aware boundary test | yes |
|
|
65
|
+
| A symlink inside a root pointing out of it | Resolved before the containment check for anything read or written through; the walk uses `lstat` and never traverses a link | yes, read and write side |
|
|
66
|
+
| A symlink given as the item to move, rename or trash | The link itself is acted on, never its target; containment is checked on the link's own position | yes |
|
|
67
|
+
| A dangling symlink on a write path | Refused (`dangling_symlink`) wherever a write would pass through it. At a destination it is an existing entry: shown in the preview, refused without `overwrite`, and moved to the trash itself with it | yes, including across devices |
|
|
68
|
+
| A destination under a symlinked parent | Nearest existing ancestor resolved, remaining segments re-appended, checked before the write | yes |
|
|
69
|
+
| The trash directory redirected by a symlink | `.shieldfive-mcp-trash` is `lstat`'d while planning, then created one level at a time without following anything and checked again before anything moves in; a link or a non-directory there is refused (`trash_unsafe`) | yes |
|
|
70
|
+
| The trash on a different volume from the item | An item's trash is in the highest directory between it and its root on the item's own device; a move into the trash is a rename and never a copy; a mount point cannot be trashed (`trash_no_same_volume`) | yes, on a RAM disk |
|
|
71
|
+
| Irreversible deletion | Nothing of the user's is removed except the source of a move that crosses a device, after its copy is verified. `trash_local` renames; an overwriting `move_local` moves the displaced item to the trash | yes |
|
|
72
|
+
| A cross-device move losing data | The copy is made under a fresh, exclusively created name; symlinks and special files in the tree are refused; each file is flushed and verified by size and SHA-256 against a source that has not changed; the source is removed entry by entry, and only what is unchanged since it was copied | yes, on a RAM disk, with a corrupt copy and a changing source injected |
|
|
73
|
+
| A rename or move replacing something | Operations that fail when the destination exists: a hard link then an unlink for files, a recreated symlink, a rename over an empty placeholder for directories | yes, with the race injected |
|
|
74
|
+
| A partial failure losing track of moved files | Each call has its own batch directory; its manifest lists every item before any moves, is written atomically and one write at a time, and a partial failure names each moved item and its manifest, with the detail returned to the client | yes |
|
|
75
|
+
| A corrupt or foreign manifest being overwritten | No existing manifest is read, merged or replaced; the first write of a manifest fails if anything is at its name | yes |
|
|
76
|
+
| An assistant acting without the user seeing the plan | Mutating tools are inert without `confirm: true` and report what they would displace as well as what they would move | yes |
|
|
77
|
+
| An oversized argument | `limit`, `max_files`, `max_files_hashed`, `paths`, path length and `new_name` are capped at the MCP schema and again in the handler; values echoed in a refusal are cut short | yes |
|
|
78
|
+
| A cancelled request still changing files | Nothing starts once a request is cancelled; a trash batch stops between items and says what moved; the outcome is logged, because the SDK sends no response to a cancelled request | yes |
|
|
79
|
+
| Credential exposure in this code | No credential is read or stored. `SHIELDFIVE_MCP_ROOTS` is the only environment variable read | yes |
|
|
80
|
+
| Exfiltration over the network | No networking module imported, `fetch` never called; the transport is stdio | yes, for this package's source |
|
|
81
|
+
| Credential exposure via a subprocess | No subprocess is spawned at all. Spawning `sf` would inherit `SF_PASSWORD` from the environment whether or not this code named it | yes |
|
|
82
|
+
|
|
83
|
+
## Known limits
|
|
84
|
+
|
|
85
|
+
- **A plan binds what it named, not everything it implies.** `confirm: true`
|
|
86
|
+
requires the `plan_token` its own preview issued, and refuses when the tree no
|
|
87
|
+
longer matches what that preview described — the paths, what each entry is
|
|
88
|
+
(device, inode, size, modification time), and the file and byte counts beneath
|
|
89
|
+
a directory. A file edited in place to exactly the same length within the same
|
|
90
|
+
timestamp resolution is not detected. The token is single use and expires
|
|
91
|
+
after ten minutes.
|
|
92
|
+
- **Time-of-check to time-of-use.** Containment resolves a path and then acts on
|
|
93
|
+
it. An attacker who can replace a directory with a symlink between those two
|
|
94
|
+
steps defeats it. The window is kept narrow — a destination is re-resolved
|
|
95
|
+
immediately before a write, and the trash directory is checked again after it
|
|
96
|
+
is created — but it is not closed, and closing it needs `openat2`-style
|
|
97
|
+
primitives Node does not expose. It presupposes write access inside a root,
|
|
98
|
+
which is already a compromise of the thing being protected.
|
|
99
|
+
- **"Never replaces" has a residual window in two places.** Node exposes neither
|
|
100
|
+
`renameat2(RENAME_NOREPLACE)` nor `renamex_np(RENAME_EXCL)`. A directory is
|
|
101
|
+
renamed over an empty placeholder made a moment before, so an *empty*
|
|
102
|
+
directory created in its place in that instant would be replaced, which loses
|
|
103
|
+
nothing. FIFOs, sockets and device files, filesystems without hard links (FAT,
|
|
104
|
+
exFAT, some network shares) and directories on Windows fall back to checking
|
|
105
|
+
and then renaming, where a file created at the destination in between would
|
|
106
|
+
be replaced.
|
|
107
|
+
- **Hardlinks are not resolved.** `realpath` follows symlinks, not hardlinks, so
|
|
108
|
+
a hardlink inside a root that references an inode also reachable outside every
|
|
109
|
+
root reads as contained, because it genuinely is one of that inode's names.
|
|
110
|
+
Files with a link count above one are counted and reported in scan warnings
|
|
111
|
+
rather than silently trusted. `find_duplicates` counts names of one inode as
|
|
112
|
+
one copy; the totals in `list_local` and `storage_summary` count every name.
|
|
113
|
+
- **APFS clones are invisible.** A clone shares storage with its original but is
|
|
114
|
+
a separate file that nothing readable distinguishes from a real copy, so
|
|
115
|
+
`find_duplicates` reports clones as reclaimable when trashing one frees little
|
|
116
|
+
or nothing.
|
|
117
|
+
- **A cross-device move is as durable as the drive.** Each copied file is flushed
|
|
118
|
+
with `fsync` and verified before its original is removed, and directory
|
|
119
|
+
flushes are attempted, but how much a flush guarantees is up to the drive and
|
|
120
|
+
the filesystem. A crash during a copy can leave a partial copy under a hidden
|
|
121
|
+
`.shieldfive-mcp-incoming-*` name beside the destination; the source is intact
|
|
122
|
+
until its copy is in place.
|
|
123
|
+
- **Cancellation cannot always stop a move.** A move is rolled back if it is
|
|
124
|
+
cancelled before its source starts being removed, and completed if after. The
|
|
125
|
+
MCP SDK sends no response to a cancelled request, so the outcome of a
|
|
126
|
+
cancelled mutation is written to this server's stderr log — and, for the
|
|
127
|
+
trash, to the manifest — rather than returned.
|
|
128
|
+
- **The environment is inherited, like any child process.** This code reads only
|
|
129
|
+
`SHIELDFIVE_MCP_ROOTS`, and that is asserted. It does not follow that other
|
|
130
|
+
variables are absent from the process: if the user exported `SF_PASSWORD` for
|
|
131
|
+
`@shieldfive/cli` in the shell that launched their MCP client, it is in this
|
|
132
|
+
process's address space, as it is in every other tool that client spawns. This
|
|
133
|
+
package never reads it, never stores it, and spawns no subprocess that could
|
|
134
|
+
inherit it.
|
|
135
|
+
- **The no-network assertion covers this package's source, not its dependency
|
|
136
|
+
tree.** `@modelcontextprotocol/sdk` ships HTTP transports for other people's
|
|
137
|
+
servers. This one imports the stdio transport and no HTTP transport, which is
|
|
138
|
+
asserted; the source scan matches import specifiers, so a computed dynamic
|
|
139
|
+
import would evade it.
|
|
140
|
+
- **File paths reach the model.** If a path is itself sensitive, do not give
|
|
141
|
+
this server the root it sits in.
|
|
142
|
+
- **Windows is untested.** No POSIX-only API is used and paths go through
|
|
143
|
+
`node:path`, but nobody has run it there, and the cross-device tests run only
|
|
144
|
+
on macOS.
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shieldfive/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Model Context Protocol server for local file management. Finds duplicates, large files and stale files across the drives you allow it, and reorganises them. Holds no ShieldFive credential and makes no network calls.",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "Cho Garcia <security@shieldfive.com>",
|
|
8
|
+
"homepage": "https://github.com/shieldfive/mcp",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/shieldfive/mcp.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/shieldfive/mcp/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"shieldfive",
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"claude",
|
|
21
|
+
"filesystem",
|
|
22
|
+
"duplicate-files",
|
|
23
|
+
"disk-space"
|
|
24
|
+
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"shieldfive-mcp": "src/server.mjs"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"README.md",
|
|
34
|
+
"SECURITY.md",
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test test/*.test.mjs",
|
|
43
|
+
"demo": "node demo/run-demo.mjs"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
47
|
+
"zod": "^3.25.0"
|
|
48
|
+
}
|
|
49
|
+
}
|