loom-fs
by denislamard
README.md
# loom-fs — serveur MCP filesystem à rôles
Serveur MCP qui expose une vue restreinte du système de fichiers à un agent. Chaque
répertoire accessible est déclaré explicitement, avec un rôle qui détermine ce qui
peut y être fait.
Écrit pour remplacer `@modelcontextprotocol/server-filesystem`, qui accorde
lecture, écriture et suppression sur toute racine passée en argument, sans
granularité ni filtrage des fichiers sensibles.
---
## Modèle de sécurité
### Le rôle est un plafond
Un rôle définit le maximum de droits accordables. Une racine peut restreindre en
dessous via `permissions`, jamais élargir. Une tentative d'élargissement échoue au
chargement de la configuration, pas à l'exécution.
| Rôle | Plafond | Usage visé |
|---|---|---|
| `reference` | `read` | documentation, specs, données à ne jamais modifier |
| `sourcecode` | `read`, `write` | un dépôt de code — modifie, ne supprime pas |
| `workspace` | `read`, `write`, `delete` | dossier de travail jetable |
### Le point de passage unique
`security.resolve()` est le seul endroit qui transforme une chaîne fournie par le
modèle en chemin réel. Tous les outils y passent. Il applique trois contrôles dans
cet ordre, et le message d'erreur indique toujours lequel a mordu :
1. **Permission** — l'opération est-elle dans les droits de la racine ?
→ `root 'x' (role reference) grants [read], not write`
2. **Confinement** — `os.path.realpath` est appliqué à la cible *et* à la racine,
puis on vérifie l'inclusion sur les chemins résolus.
→ `path escapes the root`
3. **Motifs de refus** — appliqués à *chaque composant* du chemin relatif.
→ `matches deny pattern '.env'`
Résoudre les symlinks avant le contrôle d'inclusion est ce qui fait échouer un lien
sortant ici plutôt qu'au `open()`. Une racine atteinte *via* un symlink fonctionne
normalement : elle est résolue une fois au chargement.
Les cibles inexistantes résolvent correctement — `realpath` résout le préfixe
existant et concatène le reste — donc la création de fichier ne bute pas sur le
contrôle de confinement.
### Motifs de refus
Trois niveaux qui s'additionnent, jamais ne se soustraient :
1. `BASE_DENY`, en dur dans le code, non désactivable ;
2. `deny` au niveau de la configuration, appliqué à toutes les racines ;
3. `deny` d'une racine particulière.
```
.env .env.* *.pem *.key *.p12 *.pfx
id_rsa* id_ecdsa* id_ed25519*
.ssh .aws .gnupg .netrc .npmrc .pypirc
.git .venv node_modules __pycache__ .mcp-trash
```
La liste de base reste dans le code délibérément. Si elle passait en configuration,
elle deviendrait retirable — et c'est exactement le genre de ligne qu'on commente
« juste pour ce test » un soir. Une garantie qu'un fichier de config peut désactiver
n'est plus une garantie. `extra = "forbid"` sur `Config` fait qu'une tentative de la
redéfinir depuis le TOML échoue au chargement au lieu d'être ignorée en silence.
Ces motifs filtrent aussi les résultats : un fichier refusé n'apparaît ni dans
`list_directory` ni dans `search_files`. Le modèle ne sait pas qu'il existe.
### Ce que ce filtrage n'est pas
Le filtrage porte sur des **noms**. Un secret codé en dur dans `settings.py` est lu
sans obstacle. C'est une heuristique qui attrape les cas usuels, pas une frontière.
La frontière, c'est le choix des racines. Le reste est du confort.
---
## Configuration
TOML, chemin passé via `--config`.
```toml
audit_log = "/home/[user]/.local/state/loom-fs-mcp/audit.log"
audit_log_max_bytes = 8388608 # 8 Mio par fichier
audit_log_backups = 3 # audit.log.1 … .3, soit 32 Mio au total
max_search_results = 200
max_search_bytes = 67108864 # budget total scanné par recherche
# Motifs refusés sur toutes les racines, en plus de la liste de base du code.
deny = ["*.sqlite3", "*.dump"]
[[roots]]
name = "loom"
path = "/home/[user]/dev/loom/src"
role = "sourcecode"
[[roots]]
name = "loom-docs"
path = "/home/[user]/dev/docs"
role = "reference"
[[roots]]
name = "scratch"
path = "/home/[user]/temp"
role = "workspace"
deny = ["*.tmp"]
```
### Champs d'une racine
| Champ | Obligatoire | Détail |
|---|---|---|
| `name` | oui | `^[a-z0-9][a-z0-9._-]{0,31}$`, unique |
| `path` | oui | doit exister et être un répertoire ; `~` et variables d'env développés |
| `role` | oui | `reference`, `sourcecode` ou `workspace` |
| `permissions` | non | sous-ensemble du plafond du rôle ; défaut = plafond |
| `deny` | non | motifs supplémentaires |
| `max_read_bytes` | non | défaut : 1 Mio / 2 Mio / 8 Mio selon le rôle |
### Règles de validation
Toute incohérence lève au chargement — le serveur ne démarre pas en mode dégradé.
- Un répertoire inexistant est rejeté.
- Deux racines dont l'une contient l'autre sont rejetées : la permission effective
serait ambiguë.
- Deux racines de même nom sont rejetées.
- Une racine sans aucune permission est rejetée.
---
## Outils
La surface d'outils s'adapte à la configuration : les outils d'écriture ne sont
enregistrés que si au moins une racine accorde `write`, ceux de suppression que si
une racine accorde `delete`. Avec uniquement des racines `reference`, le modèle voit
quatre outils de lecture et rien d'autre — pas un outil désactivé qu'il pourrait
tenter d'appeler.
| Outil | Permission | Détail |
|---|---|---|
| `list_roots` | — | racines, rôles, permissions effectives. À appeler en premier. |
| `list_directory` | `read` | une seule profondeur, entrées filtrées |
| `read_file` | `read` | UTF-8 ; refuse le binaire (octet nul dans les 4 premiers Kio) et le hors-limite |
| `search_files` | `read` | voir ci-dessous |
| `write_file` | `write` | refuse d'écraser sans `overwrite=true` |
| `edit_file` | `write` | remplace une occurrence exacte et **unique** ; échoue si 0 ou ≥2 |
| `move_file` | `write` | source et destination dans la même racine |
| `delete` | `delete` | déplace vers la corbeille — n'efface rien |
### Corbeille
`delete` déplace la cible vers `<racine>/.mcp-trash/<horodatage UTC>/`. Rien n'est
effacé, la restauration est manuelle. La corbeille est elle-même dans les motifs de
refus, donc inaccessible via les outils — impossible de vider la corbeille par
l'agent. La racine elle-même ne peut pas être supprimée.
---
## Recherche
`search_files(root, pattern, contains=None, max_results=None)`
### Parcours
`security.iter_files()` élague **avant** de descendre : un `.venv` ou un
`node_modules` sous la racine coûte un `fnmatch` sur son nom, pas un parcours
complet. C'est tout le gain — le reste est du détail.
Mesuré sur un arbre de 24 300 fichiers dont 300 utiles, le reste étant du `.venv` et
du `node_modules` : **1224 ms → 3,2 ms**, soit un facteur 380. La version précédente
utilisait `Path.glob()` puis filtrait, donc traversait le bruit à chaque appel.
Parcours en largeur, tri par répertoire : l'ordre est déterministe, et un résultat
tronqué contient les correspondances les plus proches de la racine plutôt qu'une
tranche arbitraire.
Les symlinks sont ignorés en bloc. Ça règle d'un coup le confinement par entrée et
les cycles — un lien circulaire ne fait plus tourner la boucle indéfiniment. Un
répertoire illisible est sauté, jamais fatal au parcours.
### Motifs
`**` traverse les répertoires, `*` et `?` restent dans un segment, `[...]` définit
une classe, `[!...]` sa négation.
Un écart assumé avec `glob` standard : **un motif sans `/` matche le nom de base à
n'importe quelle profondeur**. `*.py` trouve donc tous les fichiers Python de la
racine, ce que les gens attendent — et ça évite qu'un agent conclue à tort qu'il n'y
en a aucun. Pour la sémantique stricte, préciser `pkg/*.py`.
Sont rejetés : motif vide, absolu, contenant `..`, ou avec un `[` non fermé. Les
motifs compilés sont mémoïsés.
### Filtre `contains`
Recherche en octets, sans décodage. Avant lecture, trois gardes : la taille est
comparée au `max_read_bytes` de la racine, les 4 premiers Kio servent à détecter le
binaire, et un budget global `max_search_bytes` arrête le scan.
Chaque correspondance rend le chemin, le numéro de ligne et un extrait — ce qui évite
un `read_file` de suivi.
### Résultat
```json
{
"matches": [{"path": "loom_ia/agent.py", "line": 483, "preview": "…"}],
"truncated": false,
"examined_files": 36,
"scanned_bytes": 222742,
"skipped_too_large": 0,
"skipped_binary": 0,
"note": null
}
```
`truncated` et `note` existent pour une raison précise : une liste tronquée qui se
présente comme complète, c'est un agent qui conclut « ce symbole n'existe nulle
part » alors qu'il a vu les 200 premiers fichiers sur 3 000.
---
## Installation
```bash
cd ~/dev/loom-fs-mcp
uv sync
cp roots.example.toml roots.toml # adapter les chemins
uv run pytest # 47 tests
```
Vérifier le démarrage en direct avant de brancher le client — ça évite de debugger
à travers l'application :
```bash
uv run loom-fs-mcp --config roots.toml
```
Le serveur doit lister ses racines puis annoncer le transport `stdio`.
### Branchement dans Claude Desktop
`~/.config/Claude/claude_desktop_config.json` :
```json
{
"mcpServers": {
"loom-fs": {
"command": "/home/[user]/dev/loom-fs-mcp/.venv/bin/loom-fs-mcp",
"args": ["--config", "/home/[user]/dev/loom-fs-mcp/roots.toml"],
"env": {
"FASTMCP_SHOW_SERVER_BANNER": "false",
"FASTMCP_CHECK_FOR_UPDATES": "off"
}
}
}
}
```
Fermer complètement l'application avant d'éditer ce fichier : elle le réécrit à la
fermeture et le bloc `mcpServers` serait perdu.
---
## Journal d'audit
Une ligne `ALLOW` ou `DENY` par résolution, avec la racine, le chemin relatif et
l'opération. Rotation par `RotatingFileHandler`, bornée par `audit_log_max_bytes` et
`audit_log_backups`.
Pour purger à chaud, **tronquer, ne pas supprimer** : le serveur garde le descripteur
ouvert, et après un `rm` il continue d'écrire dans l'inode supprimé, invisible, sans
rendre l'espace avant l'arrêt du processus.
```bash
truncate -s 0 ~/.local/state/loom-fs-mcp/audit.log
```
Même raison si tu passes par logrotate : il faut `copytruncate`.
La rotation se fait dans le processus qui écrit. Plusieurs instances du serveur sur
le même fichier se marcheraient dessus — un fichier par instance, ou
`WatchedFileHandler` avec logrotate externe.
---
## Dépannage
**L'application reste bloquée au lancement.** FastMCP interroge PyPI au démarrage
(`check_for_updates`, défaut `stable`) et affiche une bannière. L'appel réseau
s'intercale entre l'`exec()` et la réponse au `initialize` ; si le réseau est lent ou
filtré, le client attend. Les deux variables d'environnement ci-dessus les coupent ;
`main()` les pose aussi par `setdefault`, donc le serveur est correct même lancé sans
environnement particulier.
`FASTMCP_CHECK_FOR_UPDATES` attend `off` — c'est un `Literal['stable', 'prerelease',
'off']`. Un booléen fait échouer la validation des réglages au démarrage.
**`uv run` comme `command`.** À éviter : `uv` résout les dépendances, et peut
installer voire télécharger un interpréteur, sans rien écrire sur stdout pendant ce
temps. Pointer directement `.venv/bin/loom-fs-mcp` supprime toute résolution entre
l'`exec()` et le handshake. Si `uv` est indispensable, ajouter `--frozen --no-sync`.
**Le serveur sort immédiatement en erreur.** Presque toujours `roots.toml` : un
répertoire déclaré n'existe pas, ou deux racines sont imbriquées. Le message le dit
explicitement — le lancer en direct pour le lire.
**Un refus inattendu.** Le message nomme la règle qui a mordu. Le journal d'audit
donne le détail.
---
## Limites connues
### Pas de quota ni de limitation de débit
Un agent en boucle peut lire des milliers de fichiers.
À noter avant d'en ajouter un : ce n'est pas un contrôle de sécurité. Si une
injection détourne l'agent, un plafond à 200 fichiers laisse fuir 200 fichiers. Ce
qu'un quota borne, c'est le coût et le rayon d'un emballement.
Et « par session » n'existe pas côté serveur : en stdio, le processus survit à toutes
les conversations. Un compteur cumulatif finirait par se déclencher et bloquerait
jusqu'au redémarrage de l'application, sans explication. S'il en faut un, ce serait un
seau à jetons — régénéré dans le temps — comptant des **octets** plutôt que des
appels : 10 000 petits fichiers ne coûtent rien, 50 fichiers de 8 Mio pèsent 400 Mio.
Pertinent le jour où ce serveur alimente un agent non surveillé. Peu utile en usage
interactif.
### `.git` est refusé
Donc pas d'historique : ni « pourquoi ce code est écrit comme ça », ni « qu'est-ce
qui a changé dans ce commit ».
L'ouvrir en accès fichier ne rendrait presque rien : les objets sont compressés, donc
le contrôle binaire refuse l'essentiel. Ce qui resterait lisible, c'est `.git/config`
— qui contient des identifiants quand une remote est en HTTPS — et les hooks, qui sont
du **code exécuté** à la prochaine opération git.
Un outil `git` dédié en lecture seule serait la bonne réponse, mais il est plus
coûteux qu'il n'y paraît :
- il travaille au niveau du dépôt, donc sur un périmètre plus large que les racines ;
- l'historique contourne la liste de refus — un `.env` commité il y a deux ans est
toujours dans les objets — il faudrait donc dériver des pathspecs `:(exclude,glob)`
de la même liste, soit une seconde source de vérité à maintenir ;
- lancer `git` dans un dépôt exécute la configuration **de ce dépôt** : alias,
`core.pager`, `diff.external`, filtres `textconv` sont autant de commandes shell.
Sur un dépôt cloné, c'est une exécution de code arbitraire déclenchée par un outil
« lecture seule ». Neutralisation explicite obligatoire.
Écarté pour l'instant : l'archéologie git se fait en session Claude Code, dans le
dépôt, où git fonctionne nativement. À reconsidérer si le besoin apparaît en Chat.
### Le processus tourne hors VM
Avec les droits de l'utilisateur. Les racines sont la seule barrière réelle : leur
choix est la décision de sécurité, tout le reste est de la défense en profondeur.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues