@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/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
+ }