Use Warden
Isolated Code Mode relay
Run a protocol-aware code helper with fd 3, empty environment, private temporary storage and bounded lifecycle.Cette page décrit le chemin d'exécution qui permet à un provider Node de
piloter maelys-code-runner sans lui transmettre directement un descripteur de
fichier et sans exécuter le JavaScript généré dans le processus du provider.
Disponibilité. Ce chemin est publié à partir de la release
v0.13.0.
Vue d'ensemble
Le chemin utile peut se lire plus simplement ainsi :
Provider Node
│ stdin/stdout de maelys-warden (octets opaques)
▼
maelys-warden run --ro --io isolated-duplex
│ socketpair, extrémité enfant installée sur fd 3
▼
maelys-code-runner sous Seatbelt ou BubblewrapLe CLI effectue une copie des octets dans les deux directions. Cette copie
évite SCM_RIGHTS, un addon Node natif et le passage d'un fd préexistant à
Executor. Pour quelques dizaines de trames JSON par exécution, son coût est
secondaire par rapport au spawn et à l'exécution QuickJS.
Executor ne connaît ni code-bridge-v1, ni JSON, ni les outils Hermes. Il
transporte des octets et applique le plan d'exécution. Le provider conserve la
validation des schémas, la politique d'effets et le routage des appels d'outils.
Responsabilités des couches
| Couche | Responsabilité | Ne fait pas |
|---|---|---|
| Provider Node | choisit la policy, accorde la liste d'outils, traite tool.call, valide les arguments et résultats | n'exécute pas le JavaScript généré dans son propre contexte |
| SDK Node | crée les répertoires privés, lance le CLI, expose protocol, stderr, wait() et stop() | ne parse pas code-bridge-v1 et ne décide pas des permissions |
maelys-warden | relaie stdin/stdout vers le canal protocolaire, propage l'arrêt, draine la sortie et écrit les receipts | ne comprend pas les trames transportées |
libmaelys-warden | compile policy inline/JSON/MIR, résout le host, compose Sandbox–Executor–Egress et expose le lifecycle | ne remplace pas le moteur de sandbox |
| Executor | crée le socketpair, fixe le fd 3, ferme les autres fd, lance le backend et supervise le processus | ne valide pas le protocole applicatif |
| Seatbelt/Bubblewrap | applique les droits filesystem, réseau et processus annoncés par le plan | ne corrige pas une policy trop permissive |
maelys-code-runner | exécute QuickJS et parle code-bridge-v1 sur fd 3 | n'obtient aucune capacité directe sur les outils du provider |
Le contrat des descripteurs
Le provider voit un processus maelys-warden ordinaire :
Provider maelys-warden
child.stdin ──────────────────► stdin
child.stdout ◄────────────────── stdout protocole uniquement
child.stderr ◄────────────────── stderr diagnostics uniquementDans cli/maelys-warden.c, trois pompes indépendantes relaient ensuite les
flux. En mode isolated-duplex, libmaelys-warden demande
MAELYS_EXEC_IO_ISOLATED_DUPLEX et fixe isolated_child_fd à 3. Executor
crée un socketpair et installe les descripteurs du runner comme suit :
| Descripteur du runner | Contenu |
|---|---|
0 | /dev/null |
1 | duplication de stderr |
2 | diagnostics capturés par le CLI |
3 | canal protocolaire full-duplex isolé |
4+ | fermés, sauf capacité additionnelle explicitement installée par une autre policy |
Une écriture accidentelle du runner sur stdout rejoint donc stderr et ne
corrompt pas les trames de code-bridge-v1. Inversement, le stdout du CLI est
réservé au canal protocolaire : tout diagnostic du CLI doit aller sur
stderr.
La fermeture de stdin côté provider produit une demi-fermeture SHUT_WR du
socket. Le sens runner vers provider reste ouvert, ce qui permet au CLI de
relayer la dernière trame avant l'EOF.
Ce chemin n'utilise jamais MAELYS_PRESERVE_FDS. Le fd 3 est un contrat natif
d'Executor. La convention MAELYS_PRESERVE_FDS ne concerne que d'éventuels
canaux supplémentaires à partir du fd 4.
Ce que fait spawnIsolated()
L'API est exportée par sdk/node/index.js et typée dans
sdk/node/index.d.ts :
spawnIsolated(command: string[], options: IsolatedOptions): IsolatedExecutionElle :
- valide la commande, la policy et le frontend réseau ;
- crée une racine de contrôle privée en mode
0700; - crée un workspace par défaut et un répertoire
sandbox-tempdistincts ; - lance
maelys-wardenavec--io isolated-duplexet--environment empty; - expose un
DuplexNode combinantchild.stdinetchild.stdout; - expose séparément
child.stderr; - lit les receipts après la fin du CLI ;
- détruit la racine privée après
wait().
L'objet retourné contient :
interface IsolatedExecution {
protocol: Duplex;
stderr: Readable;
pid: number | undefined;
wait(): Promise<IsolatedResult>;
stop(): Promise<void>;
}wait() et stop() sont idempotents. Le résultat de wait() contient le code
ou signal du CLI, le receipt Executor et les éventuels receipts réseau.
Environnement vide
--environment empty vise le runner : il ne reçoit pas l'environnement
ambiant du provider, donc ni token, ni variable de credential, ni PATH
hérité. Le chemin de l'exécutable est résolu par le launcher avant le spawn.
Le processus maelys-warden, lui, reçoit l'environnement du provider augmenté
de launcherEnvironment et d'un TMPDIR privé. Cette distinction est
nécessaire : libmaelys-warden lit le TMPDIR du launcher pour résoudre la
racine temporaire autorisée par le plan. Cette variable n'est pas réinjectée
dans l'environnement vide du runner.
Espace temporaire privé
Le SDK crée sandbox-temp sous une racine 0700, puis la policy inline l’accorde en
écriture au runner. Les fichiers de policy et de receipt restent dans la racine
de contrôle, hors de la racine temporaire writable du sandbox.
« Privé » signifie ici : nom aléatoire, permissions utilisateur 0700, accès
limité par le plan et suppression à la fin de wait(). Ce n'est ni un tmpfs,
ni un effacement sécurisé, ni une protection contre le même compte Unix ou
root sur l'hôte.
Réseau interdit
spawnIsolated() est générique : la fonction seule n'interdit pas le
réseau. La garantie Code Mode vient de policy: readOnly(), dont le
MIR demande network:none, un workspace en lecture seule, une racine temporaire
en écriture et le confinement de l'arbre de processus.
Avec cette policy, libmaelys-warden sélectionne Seatbelt sur macOS Apple Silicon et
Bubblewrap sur Linux. Si le backend ne peut pas appliquer les capacités
requises, le lancement échoue ; il n'existe aucun fallback silencieux vers le
backend POSIX.
Une policy JSON/MIR différente peut accorder davantage de filesystem ou un réseau médié. Le provider Code Mode ne doit donc jamais accepter sa policy depuis du code généré ou depuis un dépôt non fiable.
Cycle de vie Code Mode
Le provider doit drainer protocol et stderr pendant toute l'exécution.
Attendre la fin sans lire un flux borné peut provoquer une contre-pression et
bloquer le processus.
Arrêt et annulation
stop() envoie SIGTERM au processus maelys-warden. Le gestionnaire du CLI se
limite à poser un indicateur async-signal-safe ; la boucle principale appelle
ensuite maelys_warden_execution_shutdown().
L'arrêt Executor suit trois étapes :
- vérification immédiate de l'état ;
- arrêt gracieux, puis attente bornée ;
- arrêt forcé, puis seconde attente bornée.
Les délais par défaut de la requête Executor sont actuellement de 1000 ms
pour le rung gracieux et 1000 ms pour le rung forcé. Si l'arrêt forcé est
tenté, le receipt positionne forcedStopAttempted à true.
Cette échelle est la protection externe contre un runner qui ne répond plus.
Elle complète le timeout coopératif de maelys-code-runner; elle ne le
remplace pas. Pour conserver une trame terminale explicative, le timeout du
runner doit expirer avant la fenêtre d'escalade du launcher.
Executor supervise le domaine que son backend sait contenir. Le modèle de sécurité documente encore une limite : un descendant hostile peut échapper au groupe de processus POSIX et la supervision complète d'un arbre hostile n'est pas revendiquée sur tous les backends.
Exemple Node minimal
L'exemple suivant exécute une fonction autorisée et répond à l'appel d'outil du runner. Le framing applicatif est JSON Lines, mais Executor ne l'interprète pas :
const readline = require('node:readline');
const { readOnly, spawnIsolated } = require('@maelys/warden');
async function main() {
const execution = spawnIsolated(['/usr/local/bin/maelys-code-runner'], {
policy: readOnly(),
invocationId: 'example-code-mode',
});
execution.stderr.on('data', chunk => process.stderr.write(chunk));
const lines = readline.createInterface({
input: execution.protocol,
crlfDelay: Infinity,
});
lines.on('line', line => {
const frame = JSON.parse(line);
if (frame.type === 'tool.call' && frame.name === 'math.double') {
execution.protocol.write(JSON.stringify({
version: 'code-bridge-v1',
type: 'tool.result',
id: frame.id,
ok: true,
result: { value: frame.arguments.value * 2 },
}) + '\n');
}
});
execution.protocol.write(JSON.stringify({
version: 'code-bridge-v1',
type: 'execute',
executionId: 'example-code-mode',
code: "return await tools['math.double'](https://github.com/maelys-dev/maelys-warden/blob/main/docs/{value: 21});",
tools: [{ name: 'math.double' }],
limits: {
maxToolCalls: 1,
maxResultBytes: 4096,
timeoutMs: 1000,
},
}) + '\n');
const result = await execution.wait();
if (result.returncode !== 0) {
throw new Error(`runner failed: ${result.returncode ?? result.signal}`);
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});En production, le provider doit aussi corréler les identifiants, valider chaque
frame, borner les tailles, refuser les outils non accordés et attendre une
execution.result terminale avant de considérer l'exécution comme réussie.
Erreurs et autorité du résultat
- Une erreur de spawn Node rejette
wait(). - Un argument CLI ou un mode I/O invalide produit un diagnostic sur
stderret un échec du CLI. - Un échec de compilation de policy ou l'absence d'un backend suffisamment fort empêche le spawn du runner.
- Une erreur
code-bridge-v1appartient au runner/provider ; Executor ne peut ni la créer ni l'interpréter. - Le code/signal de
wait()décrit le lifecycle du CLI. La trameexecution.resultdécrit le protocole Code Mode. Un intégrateur doit vérifier les deux. - Les receipts décrivent le backend, les digests, le réseau et l'issue. Ce sont des instantanés, pas des attestations cryptographiques.
- Si le receipt n'a pas pu être produit, le SDK peut retourner un objet vide ; l'absence de receipt ne doit pas être interprétée comme un succès de confinement.
Limites et non-garanties
- Le relais est opaque : aucune validation JSON, aucun schema et aucune autorisation d'outil ne vivent dans Executor.
spawnIsolated()ne transforme pas une policy permissive en policy sûre.- L'environnement vide du runner n'empêche pas le provider ou le launcher de posséder des secrets ; il empêche leur héritage ambiant par le runner.
- Executor ne revendique actuellement ni quotas cgroup, ni allowlist exhaustive de syscalls, ni protection contre une vulnérabilité du noyau.
- Seatbelt est pris en charge sur macOS 15+ Apple Silicon et dépend d'une API Apple dépréciée. Bubblewrap dépend des user namespaces Linux.
- Le backend POSIX fournit le lifecycle, pas un sandbox adversarial. Un plan
Code Mode exigeant
NETWORK_NONEet le confinement ne doit pas être abaissé vers POSIX. - La racine temporaire est supprimée après
wait(), pas nécessairement dès la réception de la trame terminale. - La sécurité mémoire, les plafonds QuickJS et la grammaire
code-bridge-v1appartiennent àmaelys-code-runner, pas à ce dépôt.
Vérification
Depuis la racine de maelys-warden :
# Compilation du CLI et présence de l'option.
make cli-check
# Relais fd 3, séparation des diagnostics, drain terminal et escalade forcée.
make cli-e2e
# Contrat public libmaelys-warden et SDKs, dont spawnIsolated().
make warden-check
make sdk-check
# Intégration réelle avec maelys-code-runner déjà construit.
make -C ../maelys-code-runner all
make code-runner-e2e \
CODE_RUNNER=../maelys-code-runner/build/release/maelys-code-runnerLe test d'intégration final vérifie notamment :
- la séquence
ready→tool.call→execution.result; - l'absence de
fetchetprocessdans QuickJS ; - le résultat d'un aller-retour d'outil ;
network.mode = nonedans le receipt ;- le backend
seatbeltsur macOS oububblewrapsur Linux.
Pour inspecter manuellement le transport sans Code Mode, le test CLI utilise un programme qui lit et écrit directement sur le fd 3. Il vérifie aussi qu'un diagnostic sur stdout du runner arrive sur stderr du CLI et que la dernière trame n'est pas perdue.