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 :

CODE
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 Bubblewrap

Le 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

CoucheResponsabilitéNe fait pas
Provider Nodechoisit la policy, accorde la liste d'outils, traite tool.call, valide les arguments et résultatsn'exécute pas le JavaScript généré dans son propre contexte
SDK Nodecré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-wardenrelaie stdin/stdout vers le canal protocolaire, propage l'arrêt, draine la sortie et écrit les receiptsne comprend pas les trames transportées
libmaelys-wardencompile policy inline/JSON/MIR, résout le host, compose Sandbox–Executor–Egress et expose le lifecyclene remplace pas le moteur de sandbox
Executorcrée le socketpair, fixe le fd 3, ferme les autres fd, lance le backend et supervise le processusne valide pas le protocole applicatif
Seatbelt/Bubblewrapapplique les droits filesystem, réseau et processus annoncés par le planne corrige pas une policy trop permissive
maelys-code-runnerexécute QuickJS et parle code-bridge-v1 sur fd 3n'obtient aucune capacité directe sur les outils du provider

Le contrat des descripteurs

Le provider voit un processus maelys-warden ordinaire :

CODE
Provider                         maelys-warden
child.stdin  ──────────────────► stdin
child.stdout ◄────────────────── stdout   protocole uniquement
child.stderr ◄────────────────── stderr   diagnostics uniquement

Dans 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 runnerContenu
0/dev/null
1duplication de stderr
2diagnostics capturés par le CLI
3canal 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 :

CODE
spawnIsolated(command: string[], options: IsolatedOptions): IsolatedExecution

Elle :

  1. valide la commande, la policy et le frontend réseau ;
  2. crée une racine de contrôle privée en mode 0700 ;
  3. crée un workspace par défaut et un répertoire sandbox-temp distincts ;
  4. lance maelys-warden avec --io isolated-duplex et --environment empty ;
  5. expose un Duplex Node combinant child.stdin et child.stdout ;
  6. expose séparément child.stderr ;
  7. lit les receipts après la fin du CLI ;
  8. détruit la racine privée après wait().

L'objet retourné contient :

CODE
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 :

  1. vérification immédiate de l'état ;
  2. arrêt gracieux, puis attente bornée ;
  3. 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 :

CODE
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 stderr et 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-v1 appartient 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 trame execution.result dé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_NONE et 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-v1 appartiennent à maelys-code-runner, pas à ce dépôt.

Vérification

Depuis la racine de maelys-warden :

CODE
# 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-runner

Le test d'intégration final vérifie notamment :

  • la séquence ready → tool.call → execution.result ;
  • l'absence de fetch et process dans QuickJS ;
  • le résultat d'un aller-retour d'outil ;
  • network.mode = none dans le receipt ;
  • le backend seatbelt sur macOS ou bubblewrap sur 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.