diff --git a/README.de.md b/README.de.md index 417623413..8f7e06da0 100644 --- a/README.de.md +++ b/README.de.md @@ -170,6 +170,129 @@ Rezept auslassen. ______________________________________________________________________ +## Sicherheit und Befehlsinterpolation + +Ein `Netsukefile` führt Befehle aus und kann unreine Vorlagen-Helfer verwenden. +Behandeln Sie es wie ein `Makefile`: Prüfen Sie nicht vertrauenswürdige +Manifeste vor der Ausführung. Netsuke verringert einige Zitierfehler, ist aber +keine Sandbox. Auf POSIX-Shell-Routen führt die Shell selbst geschriebene +Backtick-Paare aus, wenn sie als Befehlssubstitution verwendet werden. + +**Wovor Netsuke Sie nicht schützt.** Selbst geschriebene Backticks und `$( … )` +können Befehle ausführen. Unter Unix übergibt Ninja den Befehlstext an `sh -c`; +Netsuke bereinigt diesen Text nicht. + +**Eingesetzte Werte werden nicht zitiert.** Beliebige Jinja-Werte, `raw`- +Blöcke und selbst geschriebene Shell-Fragmente werden zu gewöhnlichem +Rezepttext. Setzen Sie keine nicht vertrauenswürdigen Werte in Shell-Befehle +ein: Das Zitieren von Pfadplatzhaltern schützt diese Werte nicht. + +**Was Netsuke umschreibt.** Nur `{{ ins }}` und `{{ outs }}` sind +Netsuke-Marker. Diese Menge ist in `command:`- und `script:`-Rezepten +identisch. Alle Dollarformen unten sowie `$PATH` bleiben in beiden Rezeptarten +Shell-Variablen. Netsuke verdoppelt ihre Dollarzeichen für Ninja, damit die +ausgewählte Shell sie unverändert erhält; Ninjas eigene Regelvariablen `$in` und +`$out` werden dadurch nicht verfügbar. + +Tabelle 1: Zu Eingabe- oder Ausgabepfaden umgeschriebene Formen (`yes` +bedeutet: wird im aktiven Rezepttext umgeschrieben; siehe Hinweis unten). + +| Form | `command:` | `script:` | +| --------------------------------------------------- | ----------- | ----------- | +| `{{ ins }}` | yes[^inert] | yes[^inert] | +| `{{ outs }}` | yes[^inert] | yes[^inert] | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +[^inert]: Auf POSIX- und Bash-Routen kopiert Netsuke Marker in Kommentaren und + in Heredoc-Körpern als interne Token, statt sie zu expandieren; Marker in + Heredoc-Begrenzern werden expandiert. `script:`-Rezepte verwenden denselben + POSIX-bewussten Scanner, auch in PowerShell, während PowerShell-`command:`- + Rezepte eigenen Interpolationsregeln folgen. Verwenden Sie keine Marker in + Kommentaren oder Heredoc-Körpern: Interne Token können dort im generierten + Rezept verbleiben. + +Mit einer vorhandenen Datei `input.txt` kopiert dieses POSIX-Manifest deren +Inhalt nach `output.txt` und prüft, ob die `PATH`-Variable der Shell nicht leer +ist: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'cat {{ ins }} > {{ outs }} && test -n "$PATH"' +defaults: [output.txt] +``` + +**Was Netsuke zitiert.** Automatisches Shell-Quoting erhalten nur Netsukes +eigene Pfadersetzungen. POSIX und Bash verwenden `shell-quote` und eine +kontextgerechte Kodierung; PowerShell verwendet eine eigene Literal-Kodierung +und weist Marker in zitierten Bereichen zurück. Bei einer vorhandenen Datei +`input file.txt` übergibt dieses POSIX-Beispiel den Pfad als ein Argument und +erzeugt `output.txt`: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input file.txt + command: 'cat {{ ins }} > {{ outs }}' +defaults: [output.txt] +``` + +**Backticks und Befehlssubstitution.** Netsuke weist Marker innerhalb von +Backtick-Befehlssubstitutionen auf POSIX und Bash sowie innerhalb von `$( … )` +auf allen Shell-Routen zurück, in beiden Rezeptarten. PowerShell verwendet +Backticks als Escapezeichen und fällt nicht unter die Backtick-Einschränkung, +weist Marker in `$( … )` aber weiterhin zurück. Diese Markergrenze ist eine +zugesicherte Invariante. + +Unabhängig davon weisen POSIX- und Bash-`command:`-Rezepte derzeit eine +ungerade Gesamtzahl von Backticks nach der Ersetzung zurück. Die konservative +Zählung umfasst auch Backticks in einfachen Anführungszeichen; sie ist kein +Shell-Parser. `script:`-Rezepte und PowerShell führen diese Zählung nicht aus. +Eine künftige Version kann ohne inkompatible Änderung mehr akzeptieren. +Ausgewogene, vom Autor geschriebene Befehlssubstitutionen bleiben aktiv. + +Dieses POSIX-Manifest wird zurückgewiesen, bevor Ninja ausgeführt wird. Führen +Sie `netsuke --json --locale en-GB` aus, um die Ursache zu sehen: +`Invalid command interpolation:`, gefolgt vom problematischen Ausschnitt. Die +menschenlesbare Standardausgabe zeigt möglicherweise nur den Fehler beim +Erstellen des Graphen: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'echo `cat {{ ins }}` > {{ outs }}' +defaults: [output.txt] +``` + +**Die `shlex`-Prüfung.** POSIX- und Bash-`command:`-Rezepte müssen nach der +Ersetzung `shlex::split` bestehen oder erhalten beim Überführen in die +Zwischendarstellung dieselbe lokalisierte Diagnose. `script:`-Rezepte und +PowerShell umgehen diese Prüfung. Netsuke führt die zurückgegebenen Tokens +nicht aus. `shlex` nimmt keine Expansion vor und behandelt Backticks als +gewöhnliche Zeichen: Das Bestehen macht einen Befehl nicht sicher. Verwenden +Sie es nicht als Einschleusungsprüfung. + +Zurückweisungen gehören zum beobachtbaren Vertrag, doch die genaue Menge +akzeptierter Eingaben ist keine Stabilitätszusage: Sie hängt von der beim +Erstellen von Netsuke aufgelösten `shlex`-Version ab. Wenn eine künftige +Version zuvor zurückgewiesenen Text akzeptiert, ist das keine inkompatible +Änderung; wenn sie zuvor akzeptierten Text zurückweist, ist das ein im +Changelog festzuhaltender Fehler, keine Richtlinienänderung. + +**Stabilität und weiterführende Informationen.** Netsuke ist vor Version 1.0; +Schnittstellen können sich noch ändern. Die begrenzten Garantien oben machen +beliebige Rezepte nicht sicher. Shell-Routen werden in der +[Sicherheitsgrenze im Benutzerhandbuch](docs/users-guide.md#review-the-safety-boundary) +erläutert; die Entscheidungen stehen in +[ADR-027](docs/adr-027-command-placeholder-contract.md). + +______________________________________________________________________ + ## Release- und Entwicklungsstatus Das Release v0.1.0-beta3 ist eine nützliche Vorschau für Früheinsteiger, keine @@ -205,10 +328,10 @@ werden können. Beta2-Manifeste, die wörtliche Shell-Dollar-Ausdrücke verwende müssen migriert werden; siehe die [Sicherheitsgrenze im Benutzerhandbuch](docs/users-guide.md#review-the-safety-boundary). -Ein `Netsukefile` kann Befehle ausführen und unreine Vorlagen-Helfer verwenden. -Es sollte mit derselben Sorgfalt behandelt werden wie ein `Makefile`: Prüfen -Sie nicht vertrauenswürdige Manifeste, bevor Sie sie ausführen. Netsuke -maskiert unterstützte Pfadersetzungen, ist jedoch keine Sandbox. +Weitere Einzelheiten finden Sie unter +[Sicherheit und Befehlsinterpolation](#sicherheit-und-befehlsinterpolation) +sowie in der +[Sicherheitsgrenze im Benutzerhandbuch](docs/users-guide.md#review-the-safety-boundary). ______________________________________________________________________ diff --git a/README.es.md b/README.es.md index 51587c371..ea47ebf1a 100644 --- a/README.es.md +++ b/README.es.md @@ -172,6 +172,128 @@ omitir una receta. ______________________________________________________________________ +## Seguridad e interpolación de comandos + +Un `Netsukefile` ejecuta comandos y puede usar auxiliares de plantillas +impuros. Trátelo con el mismo cuidado que un `Makefile`: revise los manifiestos +que no sean de confianza antes de ejecutarlos. Netsuke reduce algunos errores +de entrecomillado, pero no es un entorno aislado. En las rutas POSIX, el shell +ejecuta los pares de acentos graves escritos por el autor cuando se usan como +sustitución de comandos. + +**De qué no protege Netsuke.** Los acentos graves escritos a mano y `$( … )` +pueden ejecutar comandos. En Unix, Ninja pasa el texto del comando a `sh -c`; +Netsuke no sanea ese texto. + +**Los valores interpolados no se entrecomillan.** Los valores arbitrarios de +Jinja, los bloques `raw` y los fragmentos de shell escritos a mano se +convierten en texto normal de receta. No interpole valores que no sean de +confianza en comandos de shell: entrecomillar los marcadores de ruta no protege +esos valores. + +**Qué reescribe Netsuke.** Solo `{{ ins }}` y `{{ outs }}` son marcadores de +Netsuke. El conjunto es idéntico en las recetas `command:` y `script:`. Todas +las formas con dólar que aparecen abajo, así como `$PATH`, siguen siendo +variables de shell en ambos tipos de receta. Netsuke duplica sus signos de +dólar para Ninja, de modo que el shell elegido los reciba sin cambios; no +expone las variables de regla propias de Ninja `$in` y `$out`. + +Tabla 1: formas reescritas como rutas de entrada o salida (`yes` significa que +se reescribe en el texto activo de la receta; consulta la nota siguiente). + +| Forma | `command:` | `script:` | +| --------------------------------------------------- | ----------- | ----------- | +| `{{ ins }}` | yes[^inert] | yes[^inert] | +| `{{ outs }}` | yes[^inert] | yes[^inert] | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +[^inert]: En las rutas POSIX y Bash, Netsuke copia los marcadores de los + comentarios y del cuerpo de los heredoc como tokens internos, sin expandirlos; + los marcadores de los delimitadores de heredoc sí se expanden. Las recetas + `script:` usan el mismo analizador POSIX, también en PowerShell, mientras que + las recetas `command:` de PowerShell siguen reglas de interpolación distintas. + No pongas marcadores en comentarios ni en cuerpos de heredoc: los tokens + internos pueden permanecer en la receta generada. + +Si existe `input.txt`, este manifiesto POSIX copia su contenido a `output.txt` +y comprueba que el `PATH` del shell no esté vacío: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'cat {{ ins }} > {{ outs }} && test -n "$PATH"' +defaults: [output.txt] +``` + +**Qué entrecomilla Netsuke.** Solo las sustituciones de rutas propias de +Netsuke reciben entrecomillado automático de shell. POSIX y Bash usan +`shell-quote` y codificación según el contexto; PowerShell usa su propia +codificación literal y rechaza marcadores en regiones entrecomilladas. Si existe +`input file.txt`, este ejemplo POSIX pasa la ruta como un solo argumento y +produce `output.txt`: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input file.txt + command: 'cat {{ ins }} > {{ outs }}' +defaults: [output.txt] +``` + +**Acentos graves y sustitución de comandos.** Netsuke rechaza marcadores dentro +de sustituciones con acentos graves en POSIX y Bash, y dentro de `$( … )` en +todas las rutas de shell, en ambos tipos de receta. PowerShell usa acentos +graves como caracteres de escape, así que queda fuera de la restricción de +acentos graves, pero sigue rechazando marcadores en `$( … )`. Este límite de +los marcadores es un invariante garantizado. + +Por separado, las recetas `command:` de POSIX y Bash rechazan actualmente un +número total impar de acentos graves tras la sustitución. Este recuento +conservador incluye los acentos graves entre comillas simples; no es un +analizador de shell. Las recetas `script:` y PowerShell omiten el recuento. Una +versión futura puede aceptar más casos sin un cambio incompatible. Las +sustituciones equilibradas escritas por el autor siguen activas. + +Este manifiesto POSIX se rechaza antes de ejecutar Ninja. Ejecute +`netsuke --json --locale en-GB` para ver la causa: +`Invalid command interpolation:`, seguida del fragmento problemático; la salida +legible predeterminada quizá muestre solo el fallo general al construir el +grafo: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'echo `cat {{ ins }}` > {{ outs }}' +defaults: [output.txt] +``` + +**La comprobación de `shlex`.** Las recetas `command:` de POSIX y Bash deben +superar `shlex::split` tras la sustitución; si no, reciben el mismo diagnóstico +localizado durante la conversión a la representación intermedia. Las recetas +`script:` y PowerShell omiten esta comprobación. Netsuke no ejecuta los tokens +devueltos. `shlex` no realiza expansiones y trata los acentos graves como +caracteres normales: superar la comprobación no hace seguro un comando. No la +use como comprobación contra inyecciones. + +El rechazo forma parte del contrato observable, pero el conjunto exacto de +entradas aceptadas no es una garantía de estabilidad: depende de la versión de +`shlex` resuelta al compilar Netsuke. Que una versión futura acepte texto antes +rechazado no es un cambio incompatible; que rechace texto antes aceptado es un +defecto que debe anotarse en el registro de cambios, no un cambio de política. + +**Estabilidad y más información.** Netsuke aún es anterior a 1.0; las +interfaces pueden cambiar. Estas garantías acotadas no hacen seguras las +recetas arbitrarias. Consulte los mecanismos de las rutas de shell en el +[límite de seguridad de la guía del usuario](docs/users-guide.md#review-the-safety-boundary) +y las decisiones en [ADR-027](docs/adr-027-command-placeholder-contract.md). + +______________________________________________________________________ + ## Estado del lanzamiento y del desarrollo El lanzamiento v0.1.0-beta3 es una vista previa útil para quienes lo adoptan @@ -211,10 +333,10 @@ expresiones literales con el signo de dólar de shell requieren migración; véa el [límite de seguridad de la guía del usuario](docs/users-guide.md#review-the-safety-boundary). -Un `Netsukefile` puede ejecutar comandos y usar ayudantes de plantilla impuros. -Trátelo con el mismo cuidado que un `Makefile`: revise los manifiestos que no -sean de confianza antes de ejecutarlos. Netsuke entrecomilla las sustituciones -de ruta admitidas, pero no es un entorno aislado. +Consulte +[seguridad e interpolación de comandos](#seguridad-e-interpolación-de-comandos) +y el +[límite de seguridad de la guía del usuario](docs/users-guide.md#review-the-safety-boundary). ______________________________________________________________________ diff --git a/README.fr.md b/README.fr.md index ee20baec1..2513ee00a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -172,6 +172,131 @@ peuvent omettre une recette. ______________________________________________________________________ +## Sécurité et interpolation des commandes + +Un `Netsukefile` exécute des commandes et peut utiliser des assistants de +modèle impurs. Traitez-le avec la même prudence qu'un `Makefile`: examinez les +manifestes non fiables avant de les exécuter. Netsuke réduit certaines erreurs +de guillemets ; ce n'est pas un bac à sable. Sur les voies shell POSIX, le +shell exécute les paires d'accents graves que vous avez écrites lorsqu'elles +servent de substitution de commande. + +**Ce contre quoi Netsuke ne protège pas.** Les accents graves écrits à la main +et `$( … )` peuvent exécuter des commandes. Sous Unix, Ninja transmet le texte +de commande à `sh -c`; Netsuke ne nettoie pas ce texte. + +**Les valeurs interpolées ne sont pas protégées par des guillemets.** Les +valeurs Jinja arbitraires, les blocs `raw` et les fragments shell écrits à la +main deviennent du texte ordinaire de recette. N'interpolez pas de valeurs non +fiables dans des commandes shell : les guillemets des marqueurs de chemin ne +protègent pas ces valeurs. + +**Ce que Netsuke réécrit.** Seuls `{{ ins }}` et `{{ outs }}` sont des +marqueurs Netsuke. L'ensemble est identique dans les recettes `command:` et +`script:`. Toutes les formes avec dollar ci-dessous, ainsi que `$PATH`, restent +des variables shell dans les deux types de recette. Netsuke double leurs signes +dollar pour Ninja afin que le shell choisi les reçoive sans changement ; il +n'expose pas les variables de règle Ninja `$in` et `$out`. + +Tableau 1 : formes réécrites en chemins d'entrée ou de sortie (`yes` signifie +que la forme est réécrite dans le texte actif de la recette ; voir la note +ci-dessous). + +| Forme | `command:` | `script:` | +| --------------------------------------------------- | ----------- | ----------- | +| `{{ ins }}` | yes[^inert] | yes[^inert] | +| `{{ outs }}` | yes[^inert] | yes[^inert] | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +[^inert]: Sur les routes POSIX et Bash, Netsuke copie les marqueurs présents + dans les commentaires et le corps des heredocs comme des jetons internes, sans + les développer ; les marqueurs des délimiteurs de heredoc sont développés. Les + recettes `script:` utilisent le même analyseur compatible POSIX, y compris avec + PowerShell, tandis que les recettes `command:` de PowerShell suivent des règles + d'interpolation distinctes. N'utilisez pas de marqueurs dans les commentaires + ni dans les corps des heredocs : les jetons internes peuvent rester dans la + recette générée. + +Si `input.txt` existe, ce manifeste POSIX copie son contenu dans `output.txt` +et vérifie que le `PATH` du shell n'est pas vide : + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'cat {{ ins }} > {{ outs }} && test -n "$PATH"' +defaults: [output.txt] +``` + +**Ce que Netsuke protège par des guillemets.** Seules ses propres substitutions +de chemin bénéficient automatiquement de la protection shell par des +guillemets. POSIX et Bash utilisent `shell-quote` et un encodage adapté au +contexte ; PowerShell utilise son propre encodage littéral et rejette les +marqueurs dans les zones entre guillemets. Si `input file.txt` existe, cet +exemple POSIX transmet le chemin en un seul argument et produit `output.txt`: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input file.txt + command: 'cat {{ ins }} > {{ outs }}' +defaults: [output.txt] +``` + +**Accents graves et substitution de commande.** Netsuke rejette les marqueurs +dans les substitutions de commande entre accents graves sous POSIX et Bash, +ainsi que dans `$( … )` sur toutes les voies shell, dans les deux types de +recette. PowerShell utilise les accents graves comme caractères d'échappement : +il échappe donc à la restriction sur les accents graves, mais rejette toujours +les marqueurs dans `$( … )`. Cette limite des marqueurs est un invariant promis. + +À part cela, les recettes `command:` POSIX et Bash rejettent actuellement tout +nombre total impair d'accents graves après substitution. Ce comptage prudent +inclut les accents graves entre apostrophes ; ce n'est pas un analyseur shell. +Les recettes `script:` et PowerShell ignorent ce comptage. Une future version +pourra accepter davantage de cas sans changement incompatible. Les +substitutions équilibrées écrites par l'auteur restent actives. + +Ce manifeste POSIX est rejeté avant l'exécution de Ninja. Lancez +`netsuke --json --locale en-GB` pour voir la cause : +`Invalid command interpolation:`, suivi de l'extrait concerné ; la sortie +lisible par défaut peut ne montrer que l'échec global de construction du graphe +: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'echo `cat {{ ins }}` > {{ outs }}' +defaults: [output.txt] +``` + +**La garde `shlex`.** Les recettes `command:` POSIX et Bash doivent réussir +`shlex::split` après substitution, sinon elles reçoivent le même diagnostic +localisé pendant la conversion en représentation intermédiaire. Les recettes +`script:` et PowerShell contournent cette garde. Netsuke n'exécute pas les +jetons renvoyés. `shlex` n'effectue aucune expansion et traite les accents +graves comme des caractères ordinaires : réussir la garde ne rend pas une +commande sûre. Ne l'utilisez pas pour vérifier l'absence d'injection. + +Le rejet fait partie du contrat observable, mais l'ensemble précis des entrées +acceptées n'est pas une garantie de stabilité : il dépend de la version de +`shlex` résolue lors de la compilation de Netsuke. Une version future qui +accepte du texte auparavant rejeté n'est pas incompatible ; rejeter du texte +auparavant accepté est un défaut à consigner dans le journal des modifications, +pas un changement de politique. + +**Stabilité et références.** Netsuke est antérieur à la version 1.0 ; les +interfaces peuvent encore changer. Ces garanties limitées ne rendent pas sûres +les recettes arbitraires. Consultez les mécanismes des voies shell dans la +[limite de sécurité du guide de l'utilisateur](docs/users-guide.md#review-the-safety-boundary) +et les décisions dans [ADR-027](docs/adr-027-command-placeholder-contract.md). + +______________________________________________________________________ + ## État de la version et du développement La version v0.1.0-beta3 constitue un aperçu utile pour les premiers @@ -209,10 +334,10 @@ ordinaires peuvent être écrites normalement. Les manifestes beta2 utilisant de expressions littérales de dollar shell nécessitent une migration ; voir la [limite de sécurité du guide de l'utilisateur](docs/users-guide.md#review-the-safety-boundary). -Un `Netsukefile` peut exécuter des commandes et utiliser des assistants de -modèle impurs. Traitez-le avec la même prudence qu'un `Makefile`: examinez les -manifestes non fiables avant de les exécuter. Netsuke met entre guillemets les -substitutions de chemin prises en charge, mais ce n'est pas un bac à sable. +Consultez +[sécurité et interpolation des commandes](#sécurité-et-interpolation-des-commandes) +et la +[limite de sécurité du guide de l'utilisateur](docs/users-guide.md#review-the-safety-boundary). ______________________________________________________________________ diff --git a/README.ja.md b/README.ja.md index 7db211e80..d0fa99ffa 100644 --- a/README.ja.md +++ b/README.ja.md @@ -149,6 +149,124 @@ beta3リリースは、依存関係のみのアクションおよびターゲッ ______________________________________________________________________ +## セキュリティとコマンド補間 + +`Netsukefile`はコマンドを実行し、副作用のあるテンプレートヘルパーを +使用できます。`Makefile`と同じように注意して扱い、信頼できない +マニフェストは実行前に確認してください。Netsukeは一部のクォートミスを +減らしますが、サンドボックスではありません。POSIXシェル経路では、 +自分で記述したバッククォートのペアがコマンド置換として使われる場合、 +シェルがそれを実行します。 + +**Netsukeが防げないこと。** 手書きのバッククォートと `$( … )` は +コマンドを実行できます。UnixではNinjaがコマンドテキストを `sh -c` +に渡します。Netsukeはそのテキストをサニタイズしません。 + +**テンプレートに埋め込む値はクォートされません。** 任意のJinja値、 +`raw`ブロック 、手書きのシェル断片は通常のレシピテキストになります。 +信頼できない値をシェルコマンドに埋め込まないでください。パス +プレースホルダーのクォートでは、それらの値は保護されません。 + +**Netsukeが書き換えるもの。** Netsukeのマーカーは `{{ ins }}` と `{{ outs }}` +だけです。この集合は `command:` と `script:` の +どちらのレシピでも同じです。下記のドル記号形式と `$PATH` は両方の +レシピでシェル変数のままです。NetsukeはNinja用にドル記号を二重化し、 +選択されたシェルにそのまま渡します。Ninja固有のルール変数 `$in` と `$out` +を公開するものではありません。 + +表1: 入力または出力パスに書き換えられる形式 (`yes` +は書き換えられることを示します)。 + +| 形式 | `command:` | `script:` | +| --------------------------------------------------- | ----------- | ----------- | +| `{{ ins }}` | yes[^inert] | yes[^inert] | +| `{{ outs }}` | yes[^inert] | yes[^inert] | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +[^inert]: POSIXおよびBashの経路では、コメントやheredoc本文内のマーカーは + 展開されず、内部トークンのままコピーされます。heredocの区切り部分にある + マーカーは展開されます。PowerShellを含む`script:`レシピも同じPOSIX対応 + スキャナーを使いますが、PowerShellの`command:`レシピは別の補間規則に従います。 + コメントやheredoc本文にはマーカーを置かないでください。内部トークンが + 生成されたレシピに残る場合があります。 + +既存の `input.txt` がある場合、このPOSIXマニフェストは内容を `output.txt` +にコピーし、シェルの `PATH` が空でないことを確認します。 + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'cat {{ ins }} > {{ outs }} && test -n "$PATH"' +defaults: [output.txt] +``` + +**Netsukeがクォートするもの。** 自動的にシェルクォートされるのは +Netsuke自身のパス置換だけです。POSIXとBashは `shell-quote` と +コンテキストに応じたエンコードを使います。PowerShellは独自の +リテラルエンコードを使い、クォート領域内のマーカーを拒否します。 既存の +`input file.txt` がある場合、このPOSIX例は入力パスを1つの 引数として渡し、 +`output.txt` を生成します。 + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input file.txt + command: 'cat {{ ins }} > {{ outs }}' +defaults: [output.txt] +``` + +**バッククォートとコマンド置換。** +NetsukeはPOSIXとBashのバッククォートによるコマンド置換内、およびすべてのシェル経路の +`$( … )` 内にあるマーカーを、両方のレシピ種別で拒否します。PowerShellでは +バッククォートをエスケープとして使うため、その制限対象外ですが、 `$( … )` +内のマーカーは引き続き拒否します。この境界は保証された 不変条件です。 + +別途、POSIXとBashの `command:` レシピでは、置換後のバッククォート +総数が奇数の場合、現在は拒否されます。この保守的なカウントは +シングルクォート内のものも含み、シェルパーサーではありません。 `script:` +レシピとPowerShellではカウントしません。将来のリリースで、 +互換性を損なわずに受け入れる範囲が広がる可能性があります。作成者が +記述した対になったコマンド置換は引き続き有効です。 + +このPOSIXマニフェストはNinjaの実行前に拒否されます。原因を見るには +`netsuke --json --locale en-GB` を実行します。 `Invalid command interpolation:` +に続いて問題の箇所が表示されます。 +既定の人間向け出力には、グラフ構築全体の失敗だけが表示される場合が あります。 + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'echo `cat {{ ins }}` > {{ outs }}' +defaults: [output.txt] +``` + +**`shlex` のガード。** POSIXとBashの `command:` レシピは置換後に `shlex::split` +を通過する必要があります。通過しない場合、中間表現への変換中に +同じローカライズ済み診断が返されます。`script:` レシピとPowerShellは +このガードを通りません。Netsukeは返されたトークンを実行しません。 `shlex` +は展開を行わず、バッククォートを通常の文字として扱います。 +ガードを通過してもコマンドが安全になるわけではありません。インジェクション検査として使わないでください。 + +拒否は観測可能な契約の一部ですが、受け入れられる入力の正確な集合は +安定性の保証ではありません。Netsukeのビルド時に解決された `shlex` +のバージョンに依存します。将来のバージョンが以前は拒否されたテキスト +を受け入れても互換性を損なう変更ではありません。以前は受け入れられた +テキストを拒否する場合は、方針変更ではなく欠陥として変更履歴に記録 します。 + +**安定性と関連情報。** Netsukeは1.0より前のため、インターフェースは +今後も変わる可能性があります。上記の限定的な保証で任意のレシピが +安全になるわけではありません。シェル経路の仕組みは +[ユーザーズガイドの安全境界](docs/users-guide.md#review-the-safety-boundary)、 +判断の詳細は[ADR-027](docs/adr-027-command-placeholder-contract.md)を +参照してください。 + +______________________________________________________________________ + ## リリースと開発状況 v0.1.0-beta3リリースは、早期採用者にとって有用なプレビューであり、Netsukeが完成した、あるいはすべてのインターフェースが安定していることを宣言するものではありません。コンパイラパイプラインと通常のローカルビルドのワークフローは十分な内容を備えていますが、コマンドラインインターフェース、設定用語、高度なレシピモデルはまだ安定前の状態です。 @@ -173,9 +291,9 @@ beta3リリースは、Ninjaを意識したエスケープ処理によってbeta [ユーザーズガイドの安全境界](docs/users-guide.md#review-the-safety-boundary) を参照してください。 -`Netsukefile`はコマンドを実行し -、副作用のあるテンプレートヘルパーを使用できます。`Makefile`と同様の注意を払い -、信頼できないマニフェストは実行前にレビューしてください。Netsukeはサポートされるパスの置換をクォートしますが、サンドボックスではありません。 +詳しくは[セキュリティとコマンド補間](#セキュリティとコマンド補間)および +[ユーザーズガイドの安全境界](docs/users-guide.md#review-the-safety-boundary) +を参照してください。 ______________________________________________________________________ diff --git a/README.md b/README.md index 5a731f9ff..d8513f1fd 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,125 @@ nodes with a non-empty `deps` list may omit a recipe. ______________________________________________________________________ +## Security and command interpolation + +A `Netsukefile` executes commands and can use impure template helpers. Treat it +with the same care as a `Makefile`: review untrusted manifests before running +them. Netsuke reduces some quoting mistakes; it is not a sandbox. On POSIX +shell routes, a backtick pair you wrote is executed by the shell when used as +command substitution. + +**What Netsuke does not protect you from.** Handwritten backticks and `$( … )` +can execute commands. On Unix, Ninja passes command text to `sh -c`; Netsuke +does not sanitize that text. + +**Values you template in are not quoted.** Arbitrary Jinja values, `raw` +blocks, and handwritten shell fragments become ordinary recipe text. Do not +interpolate untrusted values into shell commands: path-placeholder quoting does +not protect those values. + +**What Netsuke rewrites.** Only `{{ ins }}` and `{{ outs }}` are Netsuke +markers. The set is identical in `command:` and `script:` recipes. All the +dollar forms below, and `$PATH`, remain shell variables in both recipe kinds. +Netsuke doubles their dollars for Ninja so the selected shell receives them +unchanged; it does not expose Ninja's own `$in` and `$out` rule variables. + +Table 1: Forms rewritten to input or output paths (`yes` means rewritten in +active recipe text; see the note below). + +| Form | `command:` | `script:` | +| --------------------------------------------------- | ----------- | ----------- | +| `{{ ins }}` | yes[^inert] | yes[^inert] | +| `{{ outs }}` | yes[^inert] | yes[^inert] | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +[^inert]: On POSIX and Bash routes, markers in comments and heredoc bodies + remain internal tokens instead of becoming paths; markers in heredoc + delimiters are expanded. `script:` recipes use the same POSIX-aware scanner, + including on PowerShell, while PowerShell `command:` recipes use separate + interpolation rules. Keep markers out of comments and heredoc bodies: internal + tokens there can remain in the generated recipe. + +For example, with an existing `input.txt`, this POSIX manifest copies its +contents to `output.txt` and checks that the shell's `PATH` is non-empty: + + + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'cat {{ ins }} > {{ outs }} && test -n "$PATH"' +defaults: [output.txt] +``` + +**What Netsuke quotes.** Only its own path substitutions receive automatic +shell quoting. POSIX and Bash use `shell-quote` and context-aware encoding; +PowerShell uses its own literal encoding and rejects markers in quoted regions. +With an existing `input file.txt`, this POSIX example passes the input path as +one argument, producing `output.txt`: + + + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input file.txt + command: 'cat {{ ins }} > {{ outs }}' +defaults: [output.txt] +``` + +**Backticks and command substitution.** Netsuke rejects markers inside backtick +command substitutions on POSIX and Bash, and inside `$( … )` on all shell +routes, in both recipe kinds. PowerShell uses backticks as escapes, so it is +outside the backtick restriction, but still rejects markers in `$( … )`. This +marker boundary is a promised invariant. + +Separately, POSIX and Bash `command:` recipes currently reject an odd total +count of backticks after substitution. This conservative count includes +backticks inside single quotes; it is not a shell parser. `script:` recipes and +PowerShell skip the count. A future release may accept more without a breaking +change. Balanced author-written command substitutions remain live. + +This POSIX manifest is rejected before Ninja runs. Run +`netsuke --json --locale en-GB` to see the cause +`Invalid command interpolation:` followed by the offending snippet; the default +human output may show only the enclosing graph-building failure: + + + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'echo `cat {{ ins }}` > {{ outs }}' +defaults: [output.txt] +``` + +**The `shlex` guard.** POSIX and Bash `command:` recipes must pass +`shlex::split` after substitution or receive the same localized diagnostic +during lowering. `script:` recipes and PowerShell bypass this guard. Netsuke +does not execute the returned tokens. `shlex` performs no expansion and treats +backticks as ordinary characters: passing the guard does not make a command +safe. Do not use it as an injection check. + +Rejection is part of the observable contract, but the precise accepted set is +not a stability commitment: it depends on the `shlex` version resolved when +Netsuke was built. A future version accepting previously rejected text is not a +breaking change; rejecting previously accepted text is a defect to record in +the changelog, not a policy change. + +**Stability and further reading.** Netsuke is pre-1.0; interfaces may still +change. The scoped guarantees above do not make arbitrary recipes safe. See the +[users' guide safety boundary](docs/users-guide.md#review-the-safety-boundary) +for shell-route mechanics and +[ADR-027](docs/adr-027-command-placeholder-contract.md) for these decisions. + +______________________________________________________________________ + ## Release and development status The v0.1.0-beta3 release is a useful preview for early adopters, not a @@ -218,9 +337,11 @@ escaping, so ordinary shell expressions can be written normally. Beta2 manifests that use literal shell dollar expressions require migration; see the [users' guide safety boundary](docs/users-guide.md#review-the-safety-boundary). -A `Netsukefile` can execute commands and use impure template helpers. Treat it -with the same care as a `Makefile`: review untrusted manifests before running -them. Netsuke quotes supported path substitutions, but it is not a sandbox. +Review +[Security and command interpolation](#security-and-command-interpolation) and +the +[users' guide safety boundary](docs/users-guide.md#review-the-safety-boundary) +before running untrusted manifests. ______________________________________________________________________ diff --git a/README.pt-BR.md b/README.pt-BR.md index 087d1850a..8f9d0e233 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -168,6 +168,124 @@ receita. ______________________________________________________________________ +## Segurança e interpolação de comandos + +Um `Netsukefile` executa comandos e pode usar auxiliares de modelo impuros. +Trate-o como um `Makefile`: revise manifestos não confiáveis antes de +executá-los. O Netsuke reduz alguns erros de aspas, mas não é um sandbox. Nas +rotas POSIX, o shell executa os pares de crases que você escreveu quando são +usados como substituição de comando. + +**Do que o Netsuke não protege você.** Crases escritas à mão e `$( … )` podem +executar comandos. No Unix, o Ninja passa o texto do comando para `sh -c`; o +Netsuke não higieniza esse texto. + +**Os valores interpolados não são colocados entre aspas.** Valores arbitrários +do Jinja, blocos `raw` e trechos de shell escritos à mão tornam-se texto comum +de receita. Não interpole valores não confiáveis em comandos de shell: as aspas +dos marcadores de caminho não protegem esses valores. + +**O que o Netsuke reescreve.** Somente `{{ ins }}` e `{{ outs }}` são +marcadores do Netsuke. O conjunto é idêntico nas receitas `command:` e +`script:`. Todas as formas com cifrão abaixo, assim como `$PATH`, continuam +sendo variáveis de shell nos dois tipos de receita. O Netsuke duplica os +cifrões para o Ninja, para que o shell selecionado os receba sem alterações; +isso não expõe as variáveis de regra próprias do Ninja `$in` e `$out`. + +Tabela 1: formas reescritas como caminhos de entrada ou saída (`yes` significa +que a forma é reescrita no texto ativo da receita; consulte a nota abaixo). + +| Forma | `command:` | `script:` | +| --------------------------------------------------- | ----------- | ----------- | +| `{{ ins }}` | yes[^inert] | yes[^inert] | +| `{{ outs }}` | yes[^inert] | yes[^inert] | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +[^inert]: Nas rotas POSIX e Bash, o Netsuke copia marcadores em comentários e + no corpo de heredocs como tokens internos, sem expandi-los; marcadores nos + delimitadores de heredoc são expandidos. Receitas `script:` usam o mesmo + analisador compatível com POSIX, inclusive no PowerShell, enquanto receitas + `command:` do PowerShell seguem regras de interpolação distintas. Não use + marcadores em comentários nem no corpo de heredocs: os tokens internos podem + permanecer na receita gerada. + +Se `input.txt` existir, este manifesto POSIX copia seu conteúdo para +`output.txt` e verifica se o `PATH` do shell não está vazio: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'cat {{ ins }} > {{ outs }} && test -n "$PATH"' +defaults: [output.txt] +``` + +**O que o Netsuke coloca entre aspas.** Somente as substituições de caminho +próprias do Netsuke recebem aspas de shell automaticamente. POSIX e Bash usam +`shell-quote` e codificação sensível ao contexto; o PowerShell usa codificação +literal própria e rejeita marcadores em regiões entre aspas. Se +`input file.txt` existir, este exemplo POSIX passa o caminho como um único +argumento e produz `output.txt`: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input file.txt + command: 'cat {{ ins }} > {{ outs }}' +defaults: [output.txt] +``` + +**Crases e substituição de comando.** O Netsuke rejeita marcadores dentro de +substituições com crases no POSIX e no Bash, e dentro de `$( … )` em todas as +rotas de shell, nos dois tipos de receita. O PowerShell usa crases como +escapes, então fica fora da restrição de crases, mas ainda rejeita marcadores em +`$( … )`. Esse limite dos marcadores é um invariante garantido. + +Separadamente, receitas `command:` de POSIX e Bash rejeitam atualmente uma +contagem total ímpar de crases após a substituição. A contagem conservadora +inclui crases dentro de aspas simples; não é um analisador de shell. Receitas +`script:` e PowerShell ignoram essa contagem. Uma versão futura poderá aceitar +mais casos sem alteração incompatível. Substituições equilibradas escritas pelo +autor continuam ativas. + +Este manifesto POSIX é rejeitado antes da execução do Ninja. Execute +`netsuke --json --locale en-GB` para ver a causa: +`Invalid command interpolation:`, seguida do trecho problemático; a saída +legível padrão pode mostrar apenas a falha geral ao construir o grafo: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'echo `cat {{ ins }}` > {{ outs }}' +defaults: [output.txt] +``` + +**A verificação de `shlex`.** Receitas `command:` de POSIX e Bash precisam +passar por `shlex::split` após a substituição; caso contrário, recebem o mesmo +diagnóstico localizado durante a conversão para a representação intermediária. +Receitas `script:` e PowerShell ignoram essa verificação. O Netsuke não executa +os tokens retornados. `shlex` não faz expansão e trata crases como caracteres +comuns: passar pela verificação não torna o comando seguro. Não a use para +verificar injeções. + +A rejeição faz parte do contrato observável, mas o conjunto preciso de entradas +aceitas não é um compromisso de estabilidade: depende da versão de `shlex` +resolvida ao compilar o Netsuke. Se uma versão futura aceitar texto antes +rejeitado, isso não será uma alteração incompatível; rejeitar texto antes +aceito é defeito a registrar no changelog, não uma mudança de política. + +**Estabilidade e outras leituras.** O Netsuke é pré-1.0; as interfaces ainda +podem mudar. Essas garantias delimitadas não tornam receitas arbitrárias +seguras. Consulte a mecânica das rotas de shell na +[fronteira de segurança do guia do usuário](docs/users-guide.md#review-the-safety-boundary) +e as decisões em [ADR-027](docs/adr-027-command-placeholder-contract.md). + +______________________________________________________________________ + ## Status da release e do desenvolvimento A release v0.1.0-beta3 é uma prévia útil para adotantes iniciais, não uma @@ -201,10 +319,10 @@ normalmente. Manifestos da beta2 que usam expressões literais de cifrão de shell exigem migração; veja a [fronteira de segurança do guia do usuário](docs/users-guide.md#review-the-safety-boundary). -Um `Netsukefile` pode executar comandos e usar auxiliares de modelo impuros. -Trate-o com o mesmo cuidado que um `Makefile`: revise manifestos não confiáveis -antes de executá-los. O Netsuke coloca entre aspas as substituições de caminho -suportadas, mas não é um sandbox. +Consulte +[segurança e interpolação de comandos](#segurança-e-interpolação-de-comandos) e +a +[fronteira de segurança do guia do usuário](docs/users-guide.md#review-the-safety-boundary). ______________________________________________________________________ diff --git a/README.zh-CN.md b/README.zh-CN.md index 9b2c23cd3..0a6fa20fd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -140,6 +140,109 @@ beta3版本还支持仅依赖的操作和目标聚合:`deps`列表非空的节 ______________________________________________________________________ +## 安全与命令插值 + +`Netsukefile`会执行命令,也可以使用有副作用的模板辅助函数。请像对待 +`Makefile`一样谨慎:运行不受信任的清单之前先进行审查。Netsuke可以减少 +某些引号错误,但它不是沙箱。在POSIX shell路径中,你写入的反引号对 +如果用于命令替换,就会由shell执行。 + +**Netsuke无法防护的内容。** 手写反引号和 `$( … )` 都可以执行命令。 +在Unix上,Ninja会将命令文本传给 `sh -c`;Netsuke不会清理这些文本。 + +**模板中的值不会自动加引号。** 任意Jinja值、`raw` 块和手写shell片段 +都会成为普通配方文本。不要将不受信任的值插入shell命令:路径占位符的 +加引号处理无法保护这些值。 + +**Netsuke会改写什么。** 只有 `{{ ins }}` 和 `{{ outs }}` 是Netsuke 标记。 +`command:` 和 `script:` 配方使用相同的标记集合。下表中的所有 美元符号形式以及 +`$PATH` 在两种配方中仍然是shell变量。Netsuke会为 +Ninja将美元符号加倍,使所选shell收到的内容保持不变;它不会暴露Ninja +自身的规则变量 `$in` 和 `$out`。 + +表1:会被改写为输入或输出路径的形式(`yes` 表示在配方的有效文本中改写; +请参阅下方说明)。 + +| 形式 | `command:` | `script:` | +| --------------------------------------------------- | ----------- | ----------- | +| `{{ ins }}` | yes[^inert] | yes[^inert] | +| `{{ outs }}` | yes[^inert] | yes[^inert] | +| `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output` | no | no | + +[^inert]: 在 POSIX 和 Bash 路径中,Netsuke 会将注释和 heredoc 正文中的标记原样 + 复制为内部令牌,而不会展开;heredoc 分隔符中的标记会展开。包括 PowerShell 在内的 + `script:` 配方使用同一个兼容 POSIX 的扫描器;PowerShell `command:` 配方则遵循 + 单独的插值规则。请勿在注释或 heredoc 正文中使用标记:内部令牌可能会保留在生成的 + 配方中。 + +例如,若已有 `input.txt`,以下POSIX清单会将其内容复制到 `output.txt`, +并检查shell的 `PATH` 是否非空: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'cat {{ ins }} > {{ outs }} && test -n "$PATH"' +defaults: [output.txt] +``` + +**Netsuke会为哪些内容加引号。** 只有Netsuke自己的路径替换会自动进行 +shell加引号。POSIX和Bash使用 `shell-quote` 及基于上下文的编码; +PowerShell使用自己的字面量编码,并拒绝带引号区域中的标记。若已有 +`input file.txt`,以下POSIX示例会将输入路径作为一个参数传递,并生成 +`output.txt`: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input file.txt + command: 'cat {{ ins }} > {{ outs }}' +defaults: [output.txt] +``` + +**反引号与命令替换。** POSIX和Bash中,Netsuke会拒绝位于反引号命令 +替换中的标记;所有shell路径中也会拒绝位于 `$( … )` 中的标记,两条 +规则均适用于两种配方。PowerShell使用反引号作为转义符,因此不受反引号 +限制,但仍会拒绝 `$( … )` 中的标记。这一标记边界是明确保证的不变量。 + +此外,POSIX和Bash的 `command:` 配方目前会拒绝替换后反引号总数为奇数 +的命令。这项保守计数也包括单引号内的反引号;它不是shell解析器。 `script:` +配方和PowerShell不执行此计数。未来版本可能在不造成破坏性 +变更的情况下接受更多内容。作者写入且反引号成对的命令替换仍可使用。 + +以下POSIX清单会在Ninja运行前被拒绝。运行 `netsuke --json --locale en-GB` +可查看原因: `Invalid command interpolation:`,后面附有出错片段;默认的人类可读 +输出可能只显示构建图失败这一外层错误: + +```yaml +netsuke_version: '1.0.0' +targets: + - name: output.txt + sources: input.txt + command: 'echo `cat {{ ins }}` > {{ outs }}' +defaults: [output.txt] +``` + +**`shlex` 检查。** POSIX和Bash的 `command:` 配方在替换后必须通过 `shlex::split` +,否则会在转换为中间表示时收到相同的本地化诊断。`script:` +配方和PowerShell跳过此检查。Netsuke不会执行返回的令牌。`shlex` 不会 +进行展开,并将反引号视为普通字符:通过检查并不意味着命令安全。不要 +将它用作注入检查。 + +拒绝行为属于可观察契约的一部分,但确切的可接受输入集合不属于稳定性 +承诺:它取决于构建Netsuke时解析到的 `shlex` 版本。未来版本接受以前拒绝 +的文本不属于破坏性变更;拒绝以前接受的文本则是缺陷,应记录在变更日志 +中,而不是作为策略变更处理。 + +**稳定性与延伸阅读。** Netsuke尚未达到1.0;接口仍可能变化。上述有限 +保证并不意味着任意配方都是安全的。shell路径的具体机制见 +[用户指南中的安全边界](docs/users-guide.md#review-the-safety-boundary), +相关决策见[ADR-027](docs/adr-027-command-placeholder-contract.md)。 + +______________________________________________________________________ + ## 发布与开发状态 v0.1.0-beta3版本是面向早期采用者的实用预览版,并不代表Netsuke已经完成,也不代表每个接口都已稳定;编译器管线和普通本地构建工作流已相当完善,但命令行界面、配置词汇和高级配方模型仍处于预稳定阶段。 @@ -163,9 +266,8 @@ beta3版本通过引入Ninja感知的转义,修复了beta2中shell美元符号 )的限制,因此可以正常编写普通的shell表达式;使用字面shell美元符号表达式的beta2清单需要迁移,参见 [用户指南中的安全边界](docs/users-guide.md#review-the-safety-boundary)。 -`Netsukefile`可以执行命令并使用非纯的模板辅助函数;应以对待 -`Makefile`同样的谨慎态度对待它 -:在运行不受信任的清单之前先进行审查;Netsuke会对受支持的路径替换加引号,但它并非沙箱。 +详见[安全与命令插值](#安全与命令插值)以及 +[用户指南中的安全边界](docs/users-guide.md#review-the-safety-boundary)。 ______________________________________________________________________ diff --git a/docs/adr-027-command-placeholder-contract.md b/docs/adr-027-command-placeholder-contract.md new file mode 100644 index 000000000..f2d055a81 --- /dev/null +++ b/docs/adr-027-command-placeholder-contract.md @@ -0,0 +1,304 @@ +# Architecture decision record (ADR) 027: Command placeholder contract + +## Status + +Accepted. The command placeholder set, the backtick handling boundary, and the +scope of the `shlex` guard are fixed as stated below. Roadmap item 4.4.1 +carries them to users in the +[README section](../README.md#security-and-command-interpolation) *Security and +command interpolation* and in every translated README. This record explains the +decisions behind that user-facing contract. + +Revised on 2026-09-23. The first draft of this record described the placeholder +set as differing by recipe kind, because `script:` recipes then lowered bare +`$in` and `$out` to paths. +[ADR-034](adr-034-preserve-script-in-out-as-shell-variables.md) subsequently +removed that lowering, so the set is now uniform. ADR-034 owns that decision; +this record states the resulting contract and is revised to agree with it. The +backtick and `shlex` decisions below are unaffected and were re-verified +against the implementation on the revision date. + +## Date + +2026-09-19 + +## Context and problem statement + +Netsuke compiles a YAML manifest into a Ninja build file. Part of a recipe is +Netsuke's to rewrite; the rest is opaque shell text passed to `/bin/sh`, +`bash.exe`, or Windows PowerShell. That split is a security boundary: it is the +last point at which Netsuke can reject a command whose shell syntax the +substitution damaged, and the only place Netsuke promises to quote a path. + +`docs/formal-verification-methods-in-netsuke.md` records the boundary as a +contract to settle before further proof work, and names a README section as its +eventual home. It poses three questions: + +- whether the internal marker tokens are the only supported placeholders; +- whether POSIX backtick rejection and PowerShell escape handling are the full + contract or a temporary subset of shell command-substitution handling; and +- whether `shlex::split` is part of the semantic acceptance contract or only a + guard against obviously malformed commands. + +Each question has at least two defensible answers, and the existing prose across +`docs/developers-guide.md`, `docs/users-guide.md`, and the formal-verification +document does not agree with the implementation on the first. Reading the code +settles what the behaviour *is*; it does not settle which parts of that +behaviour are promises. This record settles both. + +## Decision drivers + +- **The documented contract must be the implemented one.** A boundary a reader + cannot rely on is worse than an irregular boundary stated plainly, because + the reader will act on the reassuring reading. +- **Promises must be separated from implementation detail.** The backtick + handling contains both a genuine invariant and a conservative approximation + of it. Freezing the approximation at the strength of the invariant makes a + future correctness fix a breaking change. +- **The accepted command set is not wholly Netsuke's to define.** It is fixed + by the `shlex` version resolved at build time, and `Cargo.toml` carries a + caret requirement rather than a pin. +- **Proof claims must not outrun the proofs.** Only the marker recognizer is + covered by Kani; the backtick and `shlex` behaviour is covered by Proptest. + +## Decision + +### The placeholder set is uniform across recipe kinds + +`{{ ins }}` and `{{ outs }}` are the only Netsuke markers, and both recipe +kinds recognize that same set in active recipe text. On POSIX and Bash routes, +markers in comments and heredoc bodies are copied as internal tokens instead of +being expanded; markers in heredoc delimiters are expanded. `script:` recipes +use the same POSIX-aware scanner, including PowerShell scripts, while PowerShell +`command:` recipes use separate interpolation rules. Keep markers out of +comments and heredoc bodies because their internal tokens can remain in the +generated recipe. Every dollar-prefixed form — `$in`, `$out`, `$ins`, `$outs`, +`$input`, `$output`, `$PATH` — is a shell variable that Netsuke leaves for the +selected shell to interpret, in both recipe kinds. The Ninja backend doubles +the dollar so the shell receives the text unchanged. + +Three terms are kept distinct throughout, following +[ADR-034](adr-034-preserve-script-in-out-as-shell-variables.md): + +- a **marker** is the Netsuke-owned `{{ ins }}` or `{{ outs }}` placeholder; +- an **internal token** is the `INS_TOKEN` or `OUTS_TOKEN` sentinel created by + manifest rendering and normally consumed during interpolation; inert comments + and heredoc bodies can carry it into the generated recipe; +- a **shell variable** is handwritten text such as `$PATH` or `$in` that + Netsuke does not rewrite. + +The recognizer is `find_substitution`, which matches the two internal tokens +and nothing else. `find_script_substitution` delegates to it, so both recipe +kinds recognize the same set. POSIX-aware scanning leaves tokens in comments +and heredoc bodies unchanged, while heredoc delimiters remain eligible for +substitution. + +Netsuke does not expose Ninja's own `$in` and `$out` rule variables. Resolved +paths are baked into each content-hashed rule, so Ninja never substitutes a +path into a recipe. + +### Backtick handling is two mechanisms at two different strengths + +The mechanisms are not the same kind of thing and are not promised equally. + +**An invariant, promised.** A Netsuke placeholder is never substituted inside a +backtick region or a `$( … )` command substitution; such a recipe is rejected. +Substituting into a region the shell will re-evaluate cannot be made safe, so +the rejection is policy. It is implemented in the command traversal +(`SubstitutionTraversal::append_protected_character`, +`src/ir/cmd_interpolate/substitution.rs:364-375`) and in the script traversal +(`append_substitution_or_character`, +`src/ir/cmd_interpolate/script_substitution.rs:222-243`). + +**A conservative check, not promised.** A `command:` recipe whose substituted +text contains an odd total number of backtick characters is *currently* rejected +(`has_unmatched_backticks`, `src/ir/cmd_interpolate/mod.rs:172-174`, reached +from `is_valid_command_for_shell` at `:226-231`). The check is a whole-string +parity count, not a quoting-aware model: a backtick inside single quotes counts +toward the total, and two balanced but unrelated backticks do not. It may +therefore reject valid shell text. A future release may accept more, and such +widening is **not** treated as a breaking change. + +**Not a guarantee at all.** Netsuke leaves author-written backticks untouched. +On POSIX routes, active backticks can trigger command substitution; backticks +inside single quotes remain literal. Path substitution separately protects +Netsuke-owned values: `quote_double_quoted_path` +(`src/ir/cmd_interpolate/mod.rs:154-163`) backslash-escapes a backtick that +falls inside a *Netsuke-substituted path* landing in a double-quoted context. + +**Route and recipe-kind scope.** PowerShell sits outside the backtick half of +the invariant, because a backtick is its escape character rather than a +command-substitution delimiter, and outside the parity check entirely: +`is_valid_command_for_shell` returns `true` unconditionally for +`RecipeShell::PowerShell` (`src/ir/cmd_interpolate/mod.rs:227-228`). It is +**inside** the `$( … )` half. The PowerShell traversal treats a marker inside a +command substitution, or inside any quoted region, as protected and rejects it +(`power_shell_marker_protection`, +`src/ir/cmd_interpolate/substitution.rs:174-179`) — the same outcome as POSIX, +by a different rule, pinned by +`power_shell_rejects_markers_without_a_context_safe_encoder` +(`src/ir/cmd_interpolate_power_shell_tests.rs:9-22`). The parity check is also +`command:`-only: a `script:` recipe gets the marker invariant but no parity +check, because scripts may legitimately contain heredocs and other text that +`shlex` cannot model. + +### The `shlex` guard is part of the acceptance contract, not a stability commitment + +`is_valid_command_for_shell` calls `shlex::split(command).is_some()` on the +fully substituted text (`src/ir/cmd_interpolate/mod.rs:230`). Failure produces +`IrGenError::InvalidCommand`, surfaced through the Fluent message +`ir.invalid_command` (`locales/en-GB/messages.ftl:188`), rendered as +`Invalid command interpolation: { $snippet }.`. + +It **is** part of the observable acceptance contract: a `command:` recipe on +the POSIX or Bash route whose substituted text cannot be split is rejected with +a stable, localized diagnostic at IR-lowering time. It is **not** a stability +commitment about the precise accepted set, because that set is `shlex` 2.0.1's +approximation of POSIX and `Cargo.toml:139` is a caret requirement rather than +a pin. `Cargo.lock` records one resolution; `cargo install --path .` ignores +the lockfile, so two people building the same source can get different +acceptance sets. + +The two drift directions are named separately because they are not symmetric: + +- A `shlex` release that accepts text rejected today means previously rejected + manifests begin to build. Not treated as a breaking change. +- A `shlex` release that rejects text accepted today means a working manifest + stops building. That is a defect, not a policy change, and is recorded in the + changelog. + +The guard does not apply to `script:` recipes, and Netsuke never executes the +token vector `shlex::split` returns; it only asks whether a split was possible. +A second, `debug_assert!`-only use exists in `assert_shell_command` +(`src/ninja_gen/mod.rs:283-290`), reached only for `Recipe::Command` because +`script_shell_text` (`src/ninja_gen/mod.rs:343-356`) is explicitly exempt. + +## Options considered + +### The placeholder set + +These options were weighed while `script:` recipes still lowered `$in` and +`$out`. They are retained because the outcome was decided elsewhere, and the +reasoning explains why. + +1. **Document the recipe-kind asymmetry as retained legacy** — `{{ ins }}` and + `{{ outs }}` everywhere, plus `$in` and `$out` in scripts, with a shadowing + warning and a migration steer. Selected at first draft, on the ground that + documenting a contract Netsuke does not honour is worse than documenting an + irregular one. +2. **Document a uniform set without changing the code** — Rejected: it was + false for `script:` recipes at the time. +3. **Widen the implementation to match** — make `command:` recipes rewrite + `$in` and `$out` too. Rejected: a production behaviour change on a + security-sensitive path that would silently change the meaning of existing + commands using those names as shell variables. +4. **Narrow the implementation instead** — stop lowering `$in` and `$out` in + `script:` recipes, making the uniform set true. Not considered here; + [ADR-034](adr-034-preserve-script-in-out-as-shell-variables.md) selected it + on 2026-09-20 and it is now the implemented behaviour. It carries the same + migration risk as option 3 in the opposite direction, which ADR-034 accepted + and documented in the migration guide. + +### The backtick boundary + +1. **Scope the promise to markers, and state the rest as status** — a promised + invariant, a conservative check explicitly not promised, and an explicit + statement that author-written command substitution is executed. Selected. +2. **Promise both mechanisms as guarantees.** Rejected: it freezes a + whole-string parity approximation alongside a genuine invariant, so a future + quoting-aware fix would be a breaking change. +3. **Call the behaviour a temporary subset of a command-substitution model.** + Rejected: nothing in the roadmap commits to widening it, and the phrasing + implies a defence against author-written command substitution that does not + and is not intended to exist. + +### The `shlex` guard + +1. **Split the answer along the rejection/acceptance axis** — the rejection is + contractual, the accepted set is not. Selected. +2. **Declare it fully part of the acceptance contract.** Rejected: it would + commit the project to `shlex` 2.0.1's approximation of POSIX across future + crate versions, which nobody has agreed to, and the caret requirement makes + that commitment unstable in a way a version pin would not. +3. **Declare it only a malformed-command guard.** Rejected: users observe and + rely on the rejection, which has a stable localized diagnostic. Denying that + it is part of the contract would be false. + +## Rationale + +- **The contract is stated where it can be falsified.** The existing tests and + property tests under `src/ir/cmd_interpolate/` already pin this behaviour, + and the README section this record establishes carries a marked, executable + example for each of the three decisions. A later change that breaks the + documented behaviour therefore fails a test rather than silently invalidating + prose. +- **One decision, one owner.** The placeholder set is ADR-034's decision, not + this record's; this record states the contract that follows from it. Keeping + the superseded reasoning visible under *Options considered* shows why the + asymmetry was tolerable to document before it was removed. +- **The approximation is labelled as one.** Splitting the backtick behaviour + into a promised invariant and an unpromised check leaves room to make the + check quoting-aware later without a breaking change. +- **Stability follows the observable surface.** The rejection has a stable, + localized diagnostic and is therefore contractual; the accepted set varies + with a resolved dependency and is therefore not. + +## Consequences + +Manifest authors who relied on bare `$in` or `$out` resolving inside a +`script:` recipe must migrate to `{{ ins }}` and `{{ outs }}`. ADR-034 made +that change and the migration guide records it; the README section this record +establishes states the resulting uniform rule rather than repeating the +migration. The shadowing hazard the first draft warned about — a script +assigning its own `in` or `out` variable — no longer exists, because those +names are never rewritten. + +Authors can rely on the marker invariant and on the rejection diagnostic. +Authors cannot rely on the accepted set across `shlex` versions, and cannot +rely on Netsuke to sanitize shell text they or their templates wrote. Passing +the `shlex` guard says nothing about whether a shell would run a command +substitution in the same text: `shlex` performs no expansion and treats a +backtick as an ordinary character, so downstream tooling must not reuse +`shlex::split(x).is_some()` as an injection check. + +Widening the parity check to accept text it currently rejects is not a breaking +change; narrowing the accepted set is a defect. Renaming the diagnostic message +`ir.invalid_command` or altering its meaning is a breaking change to a +published interface. + +## Implementation references + +- Placeholder recognition: + [`src/ir/cmd_interpolate/mod.rs`](../src/ir/cmd_interpolate/mod.rs) + (`find_substitution`, and `find_script_substitution`, which delegates to it). +- Marker invariant and path quoting: the `substitution` and + `script_substitution` modules under + [`src/ir/cmd_interpolate/`](../src/ir/cmd_interpolate/). +- Marker recognizer proofs: + [`src/ir/cmd_interpolate/verification.rs`](../src/ir/cmd_interpolate/verification.rs). +- Backtick and guard properties: + [`src/ir/cmd_interpolate_property_tests.rs`](../src/ir/cmd_interpolate_property_tests.rs). +- Diagnostic text: [`locales/en-GB/messages.ftl`](../locales/en-GB/messages.ftl) + (`ir.invalid_command`). +- User-facing statement of this contract: the `README.md` section *Security and + command interpolation* added under roadmap item 4.4.1; detailed shell-route + mechanics remain in + [`users-guide.md`](users-guide.md#review-the-safety-boundary). +- Upstream requirement: `docs/formal-verification-methods-in-netsuke.md`, + sections *Kani for command interpolation* and *Command placeholder contract*. + +## Known risks and limitations + +- **Proof coverage is narrower than the contract.** Per + `docs/adr-004-bound-kani-ir-harnesses-to-small-n.md`, Kani proves the marker + recognizer only. The backtick state machine and the `shlex` guard are covered + by Proptest properties, not by proof, and are described here as + property-tested rather than proved. +- **The domain boundary in this module is not clean.** + `invalid_command_error` (`src/ir/cmd_interpolate/mod.rs:215-222`) calls the + localization layer and stores a pre-rendered, locale-resolved human string + inside the domain error. That is presentation resolved within domain policy + against ambient state. It does not affect the contract above, but this module + is not an exemplar of a pure domain boundary and should not be cited as one. +- **The `shlex` guard is not an injection defence.** It is an approximation of + word splitting, and a command that splits cleanly is not thereby safe. diff --git a/docs/contents.md b/docs/contents.md index 0aade8c6f..9ea7d0521 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -170,6 +170,9 @@ operator, user, and contributor references are easier to find. - [ADR-026](adr-026-manifest-environment-access-policy.md): Exact-name manifest environment policy evaluated before the reader, with project allow entries quarantined below the operator ceiling. +- [ADR-027](adr-027-command-placeholder-contract.md): Command placeholder set, + the backtick-handling boundary, and the scope of the `shlex` acceptance + guard, stated for the uniform marker set ADR-034 established. - [ADR-028](adr-028-defer-split-build-dir-harness-trim.md): Deferred trim of the split-build-dir harness test, with the serialized-lane measurements that made the figure unstable and the ten-run gate that reopens the question. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e0b338728..68ebc4b34 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -392,13 +392,16 @@ The lowering stages have deliberately separate responsibilities: one-based entry position. - `src/ir/from_manifest_support.rs` prepares one shell-quoted input/output binding set for the recipe, then interpolates every scalar or list entry with - that set. Only `{{ ins }}` and `{{ outs }}` markers are resolved per entry. - Literal `$ins` and `$outs` remain shell variables and are escaped for Ninja + that set. Both recipe kinds recognize the same `{{ ins }}` and `{{ outs }}` + markers; POSIX lexical scanning can preserve their internal tokens in + comments and heredoc bodies rather than resolving them. Literal `$in`, `$out`, + `$ins`, and `$outs` remain shell variables and are escaped for Ninja pass-through. POSIX lowering tracks unquoted, single-quoted, and double-quoted text, and rejects markers within command substitutions because - it cannot lower them safely; scripts therefore retain heredocs and comments - without accepting an unsafe marker context. The resulting action contains - ordinary command text and no Ninja placeholders. + it cannot lower them safely. Script recipes therefore retain heredocs and + comments without accepting an unsafe marker context. The resulting action + contains lowered command text and no Ninja rule variables; an inert region + may still retain an internal Netsuke marker token. - `src/ninja_gen/mod.rs` delegates completed recipe text to `src/ninja_gen_recipe_shell.rs`. On Unix, and for the explicit Windows Bash compatibility route, a scalar remains POSIX shell text. A list puts each @@ -3695,16 +3698,38 @@ called only by documentation-focused integration or behavioural tests. It rejects unmarked fences, duplicate identifiers and unterminated examples. `tests/documentation_examples_tests.rs` loads the exact fenced text, generates -Ninja for every manifest fence and each complete manifest linked from the -user's guide, and checks selected command and output contracts against the -current binary. On Unix, `tests/documentation_examples_e2e_tests.rs` uses real -Ninja to execute the documented first-run build and `cat hello.txt`, exercise -the configured default target, verify the photo-edit and writing outputs, and -run the standard-library manifests in isolated workspaces with controlled -fixtures, environment variables, and stub executables. The registered `fetch` -expression is intentionally checked without execution so this suite never makes -a network request. `tests/documentation_examples_loader_tests.rs` covers -concrete malformed-fence and non-YAML failure cases. +Ninja for the registered accepting manifest cases and each complete manifest +linked from the user's guide, and checks selected command and output contracts +against the current binary. On Unix, +`tests/documentation_examples_e2e_tests.rs` uses real Ninja to execute the +documented first-run build and `cat hello.txt`, exercise the configured default +target, verify the photo-edit and writing outputs, and run the standard-library +manifests in isolated workspaces with controlled fixtures, environment +variables, and stub executables. The registered `fetch` expression is +intentionally checked without execution so this suite never makes a network +request. `tests/documentation_examples_loader_tests.rs` covers concrete +malformed-fence and non-YAML failure cases. + +`tests/readme_security_tests.rs` owns the README security examples, including +an intentionally rejected manifest. Its private fixture loads the accepted +example through the shared loader. Its private +`lower_recipe_for_shell(source: &str, shell: RecipeShell)` helper returns the +lowered action recipe and `BuildGraph` after manifest parsing, rendering, and +lowering; tests call the Ninja backend separately. The POSIX generation helper +delegates to it. Keep these helpers local to this integration target: other +callers reuse the shared loader and production APIs directly. The tests inspect +shell-variable preservation, path quoting, rejection boundaries, and inert +comment/heredoc markers across POSIX/Bash command and script cases. They assert +the typed Ninja-generation failure for command heredocs and execute an inert +script heredoc with real Ninja. JSON mode exposes the rejected example's +underlying error cause. + +`tests/readme_parity_tests.rs` exercises the manual +`scripts/check-readme-parity.sh` checker against seven isolated README +fixtures. It covers matching structures, heading-count and level mismatches, +fence handling, indentation, CRLF input, a missing README, and invocation from +outside the fixture root. The test copies the actual script into the fixture; +the checker remains a manual aid and is not added as a separate CI gate. The first-run README and user's guide examples also run through the `rstest-bdd` scenarios in `tests/features/documentation_examples.feature`. @@ -3870,13 +3895,14 @@ implementation boundary, not a public command-template API. The private `src/ir/cmd_interpolate/posix_lexical.rs` helper owns the single-pass recognition of POSIX comments and heredoc inert regions. It copies -those comments and heredoc bodies byte-for-byte, leaves markers in them -literal, and queues declarations FIFO, including quoted delimiters and `<<-` -tab-stripping delimiters, so their text cannot change the surrounding quote -context. This is a command-interpolation helper, not a general shell parser, -and is not intended for reuse outside that boundary. The sibling -`src/ir/cmd_interpolate/command_substitution.rs` owns the local quote and -parenthesis state needed to keep protected `$()` bodies isolated. +those comments and heredoc bodies byte-for-byte, so internal marker tokens in +them are not expanded and can remain in the generated recipe; markers in +heredoc delimiters are expanded. It queues declarations FIFO, including quoted +delimiters and `<<-` tab-stripping delimiters, so their text cannot change the +surrounding quote context. This is a command-interpolation helper, not a +general shell parser, and is not intended for reuse outside that boundary. The +sibling `src/ir/cmd_interpolate/command_substitution.rs` owns the local quote +and parenthesis state needed to keep protected `$()` bodies isolated. `src/manifest/render.rs` may emit the internal tokens while rendering the only accepted manifest markers, `{{ ins }}` and `{{ outs }}`. Literal shell variables @@ -3886,17 +3912,28 @@ this two-stage recipe pipeline and its direct IR recipe tests. ### Command interpolation contract -The scanner recognizes only the internal `INS_TOKEN` and `OUTS_TOKEN` markers -emitted by manifest rendering. Literal shell variables such as `$in`, `$out`, -`$ins`, and `$outs` remain unchanged for the selected backend to interpret. +The scanner recognizes only the internal `INS_TOKEN` and `OUTS_TOKEN` tokens +emitted by manifest rendering, identically in `command:` and `script:` recipes. +Literal shell variables such as `$in`, `$out`, `$ins`, and `$outs` remain +unchanged for the selected backend to interpret, in both recipe kinds; see +[ADR-034](adr-034-preserve-script-in-out-as-shell-variables.md), which removed +the former `script:`-only lowering of `$in` and `$out`, and +[ADR-027](adr-027-command-placeholder-contract.md) for the resulting contract. `INS_TOKEN` and `OUTS_TOKEN` are machine-generated markers. They match exact text, so an adjacent identifier character does not suppress a marker substitution. On POSIX-compatible routes, a placeholder inside a backtick-delimited region is rejected before it can evade lowering. PowerShell -uses backticks as escapes and does not enter that protected region. The POSIX -scanner then validates the substituted command: odd backticks reject the -command, and the `shlex` guard also evaluates that substituted text. The +uses backticks as escapes and does not enter that protected region. POSIX and +Bash scanning also preserves comments and heredoc bodies verbatim; keep markers +out of those regions, where their internal tokens can remain in generated +recipe text. Heredoc delimiters are scanned for markers and expand normally. +`script:` recipes share the POSIX-aware scanner on all shell routes, while +PowerShell `command:` recipes use separate interpolation rules. For POSIX and +Bash `command:` recipes, the scanner then validates the substituted text: odd +backticks reject the command, and the `shlex` guard also evaluates that +substituted text. `script:` recipes skip both checks, because a script may +legitimately contain heredocs and other syntax `shlex` cannot model. The odd-backtick and guard properties are complementary: one proves rejection of odd substituted backtick counts, while the other proves that the guard's success or failure and returned command agree with the substituted command. diff --git a/docs/execplans/4-4-1-document-command-placeholder-contract-in-readme.md b/docs/execplans/4-4-1-document-command-placeholder-contract-in-readme.md new file mode 100644 index 000000000..067800214 --- /dev/null +++ b/docs/execplans/4-4-1-document-command-placeholder-contract-in-readme.md @@ -0,0 +1,2404 @@ +# Document the command placeholder contract in the README (roadmap 4.4.1) + +This ExecPlan (execution plan) is a living document. The sections `Constraints`, +`Tolerances (exception triggers)`, `Risks`, `Progress`, +`Surprises & discoveries`, `Decision log`, `Outcomes & retrospective`, +`Conformance basis`, and `Verification plan` must be kept up to date as work +proceeds. + +Status: COMPLETE + +Revision 3. See `Revision note` at the foot of this document. The plan was +approved and implementation began on 2026-09-19; the branch was first rebased +onto `origin/main` at `0ba6672f`, which moved the ADR ceiling from 020 to 026 +and renumbered this plan's ADR to **027** (PR 621 holds an unmerged, stale +`adr-021` filename). Several line citations in revision 2 are stale after that +rebase; every one this plan depends on has been re-verified and re-cited in +`Progress`. + +## Purpose / big picture + +Netsuke compiles a YAML manifest into a Ninja build file. Somewhere in that +pipeline it decides which parts of a recipe an author wrote are Netsuke's to +rewrite, and which are opaque shell text handed to `/bin/sh`, `bash.exe`, or +Windows PowerShell untouched. That decision is a security boundary: it is the +last place Netsuke can reject a command whose shell syntax the substitution +damaged, and the only place Netsuke promises to quote a path. + +At planning time that boundary was implemented, with marker-recognizer Kani +proofs and wider Proptest coverage, and described in three internal documents — +but a person evaluating Netsuke from its README cannot find it. The README's +only security prose is one paragraph buried inside a release-status section. +Roadmap item 4.4.1 exists to fix that, and to settle three questions the design +documents explicitly leave open. + +After this change: + +- A reader of `README.md` finds a first-class **Security and command + interpolation** section that names every supported placeholder, states what + Netsuke quotes and what it does not, states the exact backtick-handling + boundary, and states the status of the `shlex::split` guard. +- The same section exists in all six translated READMEs, so the localized + editions keep their present section-for-section parity with the English one. +- Three README examples are executable: accepted placeholders, a quoted path, + and a rejected backtick marker with a named diagnostic. All run in the + repository's documented-example harness, so the README cannot silently drift + away from the implementation. +- A new Architectural Decision Record, + `docs/adr-027-command-placeholder-contract.md`, records the three settled + contract decisions and their rationale, and the design document points at it. +- The internal documents agree with ADR-034's uniform placeholder set and + ADR-027's backtick and guard boundaries. EP-M2's original script-only + corrections were superseded upstream before README implementation. + +The change is observable by running `NETSUKE_REQUIRE_NINJA=1 make test` and +observing the new cases +`readme_security_tests::dollar_forms_are_shell_variables_in_both_recipe_kinds`, +`readme_security_tests::netsuke_owned_path_substitutions_are_quoted`, and +`readme_security_tests::documented_backtick_manifest_is_rejected` pass, and by +reading `README.md` and finding a section that agrees with what those tests +assert. + +## Context and orientation + +Assume no prior knowledge of this repository. The paragraphs below name every +file this plan reads or edits, with a repository-relative path. + +### What Netsuke does with a recipe + +A manifest (`Netsukefile`) is YAML with Jinja templating. A target or action +carries either a `command:` (a single shell command line) or a `script:` (a +multi-line shell script). Netsuke processes these in three stages: + +1. **Manifest rendering.** `src/manifest/render.rs` runs MiniJinja over the + recipe text. The manifest-author-facing markers `{{ ins }}` and `{{ outs }}` + are rendered into two internal sentinel strings, + `__NETSUKE_INS_PLACEHOLDER__` and `__NETSUKE_OUTS_PLACEHOLDER__`, referred + to in code as `INS_TOKEN` and `OUTS_TOKEN`. Arbitrary other Jinja + expressions are rendered to their values here and are thereafter + indistinguishable from text the author typed. +2. **IR lowering.** `src/ir/from_manifest_support.rs` calls one of two entry + points in `src/ir/cmd_interpolate/mod.rs`: + `interpolate_command_with_bindings` for a `command:`, or + `interpolate_script_with_bindings` for a `script:`. These replace the + internal tokens with concrete, shell-quoted input and output paths, tracking + the POSIX quoting context so a path lands correctly whether it appears + unquoted, single-quoted, or double-quoted. Quoting uses the `shell-quote` + crate (`Cargo.toml` line 137) in its `Sh` mode, not `shlex`. +3. **Ninja generation.** `src/ninja_gen_recipe_shell.rs` turns the fully + interpolated text into the `command =` binding of a content-hashed Ninja + rule. `src/ninja_gen_escape.rs` escapes `$` as `$$` so Ninja's own variable + grammar does not consume a shell dollar. Netsuke never emits Ninja's native + `$in` / `$out` rule variables; the paths are already baked into the text. + +**Term of art — "marker".** In this plan, *marker* means a Netsuke-owned +placeholder that Netsuke rewrites: `{{ ins }}`, `{{ outs }}`, and no +dollar-prefixed forms. *Internal token* means the `INS_TOKEN` /`OUTS_TOKEN` +sentinel strings that exist only between stages 1 and 2. *Shell variable* means +text such as `$PATH` or `$ins` that Netsuke deliberately leaves alone. These +three are distinct and the existing documentation conflates them; not +conflating them is a goal of this plan. + +### The three code facts this plan documents + +These were established by reading the implementation, not by trusting existing +prose. Cite them when writing. + +**Fact A — the placeholder set is uniform across recipe kinds.** +`find_substitution` (`src/ir/cmd_interpolate/mod.rs`) matches only `INS_TOKEN` +and `OUTS_TOKEN`. `find_script_substitution` delegates straight to it, so a +`script:` recipe resolves exactly the same set as a `command:` recipe. Every +dollar-prefixed form — `$in`, `$out`, `$ins`, `$outs`, `$input`, `$output`, +`$PATH` — is a shell variable that Netsuke leaves alone in both recipe kinds, +and the Ninja backend doubles its dollar so the shell receives it unchanged. + +**This fact was reversed upstream during implementation.** Until +[ADR-034](../adr-034-preserve-script-in-out-as-shell-variables.md) landed on +`main` on 2026-09-20, `script:` recipes lowered bare `$in` and `$out` to paths +via a `try_match_dollar_placeholder` helper, and revisions 1 and 2 of this plan +were built around that asymmetry. ADR-034 removed the helper. The asymmetry, +the shadowing hazard it created, and the "retained legacy" framing this plan +proposed for it are all gone. See `Surprises & discoveries` for the discovery +and `D1-RESOLVED` in `Decision log` for what replaced `D1-LEGACY`. + +**Fact B — backtick handling is two narrow checks, not a shell model.** + +- During lowering, a marker found inside a backtick region or a `$( … )` + command substitution is rejected, because substituting into a region the + shell will re-evaluate cannot be made safe. See + `SubstitutionTraversal::append_protected_character` + (`src/ir/cmd_interpolate/substitution.rs:363-375`) and the script equivalent + (`src/ir/cmd_interpolate/script_substitution.rs:221-243`). +- After substitution, and for `command:` recipes only, + `is_valid_command_for_shell` (`src/ir/cmd_interpolate/mod.rs:225-231`) + rejects the command when `has_unmatched_backticks` + (`src/ir/cmd_interpolate/mod.rs:165-174`) reports an **odd total count of + backtick characters in the whole string**. That is a parity count. It is not + quoting-aware, so a backtick inside single quotes counts, and two balanced + but unrelated backticks do not. +- PowerShell is outside the backtick half of the first check and outside the + second check entirely, but **inside** the `$( … )` half of the first. + `is_valid_command_for_shell` returns `true` unconditionally for + `RecipeShell::PowerShell` (`src/ir/cmd_interpolate/mod.rs:226-228`), because + PowerShell uses a backtick as its escape character rather than as command + substitution; confirmed by + `power_shell_bindings_preserve_literal_backticks_in_paths` + (`src/ir/cmd_interpolate_tests.rs:337-345`). A marker inside `$( … )` is + still rejected on the PowerShell route, by a different rule: + `power_shell_marker_protection` + (`src/ir/cmd_interpolate/substitution.rs:174-179`) treats any quoted region + or active command substitution as protected. Confirmed by + `power_shell_rejects_markers_without_a_context_safe_encoder`, case + `command_substitution` (`src/ir/cmd_interpolate_power_shell_tests.rs:9-22`). +- Neither check sanitizes author-written shell text. Backticks the author + typed reach the shell unchanged. On POSIX routes, active backticks can + trigger command substitution; backticks inside single quotes remain literal. + Path substitution separately protects Netsuke-owned values: + `quote_double_quoted_path` (`src/ir/cmd_interpolate/mod.rs:153-163`) + backslash-escapes a backtick inside a **Netsuke-substituted path** that lands + in a double-quoted context. + +**Fact C — `shlex::split` is a rejection gate on one route only.** +`is_valid_command_for_shell` calls `shlex::split(command).is_some()` on the +fully substituted text (`src/ir/cmd_interpolate/mod.rs:230`). Failure produces +`IrGenError::InvalidCommand`, surfaced through the Fluent message +`ir.invalid_command` — in `locales/en-GB/messages.ftl:188`, +`Invalid command interpolation: { $snippet }.` The gate applies to `command:` +recipes on the POSIX and Bash routes. It is **not** applied to `script:` +recipes (see the doc comment on `interpolate_script_with_bindings`, +`src/ir/cmd_interpolate/mod.rs:200-206`), and not on PowerShell. Netsuke never +uses the returned token vector to spawn anything; it only asks whether a split +was possible. A second, `debug_assert!`-only use exists in +`assert_shell_command` (`src/ninja_gen/mod.rs:283-290`); it is reached only for +`Recipe::Command`, because `script_shell_text` (`src/ninja_gen/mod.rs:343-356`) +is explicitly exempt. + +The dependency is **not pinned**. `Cargo.toml:138` reads `shlex = "2.0.1"`, +which is a caret requirement admitting any `2.x`; `Cargo.lock:2638` records +today's resolution, and `README.md:110` tells users to install with +`cargo install --path .`, which ignores the lockfile. Two people building the +same Netsuke source can therefore get different acceptance sets. This matters to +`D3` and is why `AX-SHLEX` is stated as an assumption rather than a fact. + +### Where the contract is currently written + +- `docs/formal-verification-methods-in-netsuke.md:263-279`, section **Command + placeholder contract**. This is the upstream requirement for 4.4.1. It asks + the project to state three things and names the README section to add. +- `docs/formal-verification-methods-in-netsuke.md:62-83`, section **Kani for + command interpolation**. Its footnote `[^8]` + (`docs/formal-verification-methods-in-netsuke.md:332-333`) cites + `src/ir/cmd_interpolate.rs`, a path that no longer exists; the module was + split into `src/ir/cmd_interpolate/`. +- `docs/developers-guide.md`, section **Command interpolation contract**. + Corrected by this branch to state the uniform set and cite ADR-034. +- `docs/users-guide.md`, section **Review the safety boundary**. `main` rewrote + this when ADR-034 landed, and it now carries the three-term glossary — + marker, internal token, shell variable — that this plan had asked for. The + README links to it rather than restating it. +- `docs/netsuke-design.md:290-298` and `docs/netsuke-design.md:2616-2644`, the + canonical design text. +- `docs/adr-004-bound-kani-ir-harnesses-to-small-n.md:167-177`, which records + that the Kani proofs cover only the marker recognizer, and that the backtick + state machine and `shlex` guard are covered by Proptest rather than proof. +- `docs/adr-014-backend-text-escaping-seam.md:42-45`, the escaping seam. + +### The README and its translations + +`README.md` is 256 lines with twelve headings and no table of contents. +Sections are separated by an `mdtablefix`-canonical thematic break, a line of +seventy underscore characters. Six translated editions — `README.de.md`, +`README.es.md`, `README.fr.md`, `README.ja.md`, `README.pt-BR.md`, +`README.zh-CN.md` — mirror the English heading structure exactly, twelve +headings each, same order, same levels. A localization menu at the top of every +edition links to the other six. + +No continuous-integration job checks translation parity. The obligation is a +convention recorded in `docs/repository-layout.md:60-65`. The translated +READMEs are excluded from the `typos` spelling gate by +`typos.local.toml:85-96`, so nothing mechanical will catch a mistranslation. +`docs/localization-glossary.md` holds the per-locale terminology tables, and +`docs/localization-styleguide.md` the tone rules. + +### The documented-example harness — the most important local constraint + +`tests/documentation_examples/mod.rs` parses `README.md`, +`docs/users-guide.md`, and `docs/stdlib-yaml-and-jinja-guide.md`. It enforces +that **every fenced code block in those three files is immediately preceded by +a marker comment** of the form ``, that the +fence declares a language, and that identifiers are unique +(`tests/documentation_examples/mod.rs:120-175`). Separately, +`tests/documentation_examples_tests.rs:18-61` holds `EXPECTED_EXAMPLE_IDS`, a +sorted list compared for **exact set equality** against the parsed identifiers. + +The consequence is that adding a fenced code block to `README.md` is not a +documentation-only edit: it is a test change. This is what makes the plan's +red-green cycle real rather than ceremonial. The translated READMEs are not in +`DOCUMENT_PATHS` and already contain unmarked fences, so they need no markers +and no registry entries. + +### Quality gates + +Run from the repository root. All are Makefile targets. + +- `make check-fmt` — `cargo fmt --check`, `ruff format --check`, and + `scripts/check-markdown-format.sh` over every Markdown file matched by + `MD_FILES_FIND` (`Makefile:126-134`). The Markdown half requires `mdtablefix` + on `PATH` and enforces canonical wrapping and table markup. +- `make markdownlint` — depends on `spelling`, then runs `markdownlint-cli2`. + `.markdownlint-cli2.jsonc` sets `MD013` line length 80 for prose, 120 inside + code blocks, `MD004` dash bullets, `MD029` ordered-list style. +- `make typecheck` — `typecheck-python` then + `cargo check --all-targets --all-features`. +- `make lint` — Clippy, the Whitaker Dylint suite, the Python lints, and the + GitHub Actions lints. +- `make test` — `cargo nextest run --workspace --all-targets --all-features` + followed by `cargo test --workspace --doc --all-features`. +- `make nixie` — validates Mermaid diagrams. This plan adds none, but the gate + scans every Markdown file, so it must still pass. + +There is **no** path filter for documentation-only pull requests +(`.github/workflows/ci.yml:4-11`); every gate runs regardless. + +## Signposts: documentation and skills + +Read before starting: + +- `AGENTS.md` — repository conventions, especially *Documentation + maintenance*, *Markdown guidance*, and *Testing*. +- `docs/documentation-style-guide.md` — the binding prose rules. Note + line 39-40: first- and second-person pronouns are forbidden *except* in + `README.md`, so the new README section may address the reader directly while + the ADR may not. Note line 108: the contents file must be updated whenever a + document is added. +- `docs/contents.md` — the documentation index; the ADR is added under + `## Decision records` (line 84), newest first, matching the ADR-020 and + ADR-019 entries at lines 146-150. +- `docs/users-guide.md` section *Review the safety boundary* (line 1649). +- `docs/developers-guide.md` section *Command interpolation contract* + (line 3186). +- `docs/netsuke-design.md` sections at lines 290-298 and 2616-2644. +- `docs/rust-testing-with-rstest-fixtures.md` — fixture and parameterization + idiom for the new tests. +- `docs/rstest-bdd-users-guide.md` — consulted and deliberately not used; see + decision `D-NO-BDD`. +- `docs/localization-glossary.md` and `docs/localization-styleguide.md` — for + milestone EP-M4. +- `docs/repository-layout.md:55-69` — the README family's stated obligations. + +Skills to load: + +- `execplans` — this document's format and living-section obligations. +- `rust-router` — routes to `rust-unit-testing` for the new `rstest` cases. +- `hexagonal-architecture` — used as a boundary check only. This plan adds no + port and no adapter; see `Conformance basis` for why that is the correct + outcome rather than an omission. +- `en-GB-oxendict` — Oxford spelling, enforced by the `typos` gate. +- `codegraph-mcp` — for navigating `src/ir/cmd_interpolate/` if the writer + needs to re-verify a fact rather than trust this plan. + +## Conformance basis + +Upstream artefacts, at the revisions current on branch +`4-4-1-document-command-placeholder-contract-in-readme`, based on `origin/main` +at commit `c31057c1` after the 2026-09-23 rebase: + +- `docs/roadmap.md`, phase 4, item **4.4.1** (lines 530-537) and its four + sub-items. Identifier used below: `RM-4.4.1`, with sub-items `RM-4.4.1.a` + (add the README section), `RM-4.4.1.b` (state the supported placeholders), + `RM-4.4.1.c` (state the backtick boundary), `RM-4.4.1.d` (state whether + `shlex::split` is part of the semantic acceptance contract). +- `docs/formal-verification-methods-in-netsuke.md`, section **Command + placeholder contract** (lines 263-279). Identifier `FV-CPC`. It poses three + open questions, referred to as `FV-CPC-Q1` (are those the only supported + placeholders), `FV-CPC-Q2` (is the POSIX backtick rejection and PowerShell + escape handling the full contract or a temporary subset), and `FV-CPC-Q3` (is + `shlex::split` part of the semantic acceptance contract or only a guard + against obviously malformed commands). +- `docs/roadmap.md` item **4.2.3** (lines 490-504), the stated prerequisite. + All six sub-items are checked. See `Risks` for the status discrepancy with + its execplan and how it is resolved. +- [ADR-034](../adr-034-preserve-script-in-out-as-shell-variables.md), accepted + on `main` 2026-09-20 — owns the placeholder-set decision this plan's `D1` + reports. It postdates revisions 1 and 2 and reverses the asymmetry they + described; `D1` and ADR-027 were revised to agree with it. +- `docs/adr-004-bound-kani-ir-harnesses-to-small-n.md` — constrains what may + be claimed as *proved* versus *property-tested*. +- `docs/adr-014-backend-text-escaping-seam.md` — the escaping seam this + contract sits above. +- No Terms of Reference document exists for this repository. Do not invent + one; `docs/roadmap.md` and `docs/formal-verification-methods-in-netsuke.md` + are the upstream artefacts. + +Trace links: + +```plaintext +RM-4.4.1.a -> EP-M3 -> README.md "Security and command interpolation" +RM-4.4.1.b / FV-CPC-Q1 -> D1 -> EP-M1 (ADR-027 §Placeholders) -> EP-M3 -> tests::readme_security::documented_safe_placeholder_manifest_builds +RM-4.4.1.c / FV-CPC-Q2 -> D2 -> EP-M1 (ADR-027 §Backticks) -> EP-M3 -> tests::readme_security::documented_backtick_manifest_is_rejected +RM-4.4.1.d / FV-CPC-Q3 -> D3 -> EP-M1 (ADR-027 §shlex guard) -> EP-M3 -> tests::readme_security::documented_backtick_manifest_is_rejected +ADR-034 (upstream) -> D1 -> ADR-027 §Placeholders -> EP-M2 (doc reconciliation) +RM-4.4.1.b / Fact A -> OBL-UNIFORM-SET -> tests::readme_security::dollar_forms_are_shell_variables_in_both_recipe_kinds +Fact A -> EP-M2 -> docs/developers-guide.md "Command interpolation contract" +Fact A -> EP-M2 -> docs/formal-verification-methods-in-netsuke.md FV-CPC +D2 quoting -> OBL-QUOTING -> tests::readme_security::netsuke_owned_path_substitutions_are_quoted +RM-4.4.1.a -> EP-M4 -> six translated READMEs +RM-4.4.1 -> EP-M5 -> docs/roadmap.md item 4.4.1 marked done +``` + +**Hexagonal-architecture conformance.** The skill's dependency rule requires +that domain logic not depend on adapters. `src/ir/cmd_interpolate/` is domain +policy: it decides what may be rewritten and what must be rejected, and it +returns a typed `IrGenError` rather than performing input or output. The shell +and Ninja adapters (`src/ninja_gen_recipe_shell.rs`, `src/runner/process/`) +consume its output. This plan writes documentation about that boundary and does +not move it. The correct architectural outcome here is **no new port and no new +adapter**: introducing one to satisfy a pattern would be a transplant, not a +boundary. Recorded as `D-NO-PORT`. + +## Constraints + +Hard invariants. A conflict with any of these requires escalation, not a +workaround. + +1. **No production behaviour change.** Do not modify any file under `src/` + other than a doc comment correction explicitly authorized by EP-M2. This is + a documentation item; the contract is documented as it is, not as it might + ideally be. If the documented behaviour looks wrong, record it in + `Surprises & discoveries` and escalate — do not fix it here. +2. **Documentation must match the implementation.** Every factual claim in the + new README section must be traceable to a cited file and line, or to a test + added by this plan. Where the implementation is narrower than a reader might + assume, say so plainly rather than omitting it. +3. **README fence discipline.** Every fenced code block added to `README.md` + carries a `` marker, declares a language, and + has a matching entry in `EXPECTED_EXAMPLE_IDS` + (`tests/documentation_examples_tests.rs`). +4. **Structural parity of the README family.** At the end of EP-M4 all seven + READMEs have the same heading count, order, and nesting. The plan may not + land with English-only parity broken across a milestone boundary other than + the explicitly declared EP-M3 → EP-M4 window. +5. **Prose rules.** en-GB Oxford spelling; prose wrapped at 80 columns; code + fences at most 120 columns; `-` bullets; sentence-case headings; no skipped + heading levels; a language identifier on every fence. Backtick any + identifier whose spelling would otherwise trip the `typos` gate. +6. **No new dependency.** Nothing is added to `Cargo.toml`. +7. **No claim of proof beyond what exists.** Per + `docs/adr-004-bound-kani-ir-harnesses-to-small-n.md:167-177`, Kani proves + the marker recognizer only. The README and ADR must not describe the + backtick guard or the `shlex` guard as *proved*; they are property-tested. +8. **`docs/users-guide.md` remains the detailed reference.** The README owns + the placeholder contract and the non-guarantees; the users' guide keeps the + shell-route mechanics, and the README does not restate them. Correcting a + factually wrong sentence there (EP-M2) is *not* weakening it — leaving the + README to contradict it would be. Do not delete guidance, and do not soften + a warning. +9. **Do not edit historical documents.** No file under `docs/archive/`, no + `docs/adr-0*.md` other than the new ADR-027, and no completed execplan. A + merged execplan records the repository at its own moment; the stale + `IN PROGRESS` header on the 4.2.3 plan is flagged, not fixed. + +## Tolerances (exception triggers) + +Stop and escalate when any threshold is reached. Do not work around them. + +- **Scope.** More than eighteen implementation files changed, or more than 1600 + net added implementation lines, excluding this living plan, normally stops + the work. On 2026-09-24 the user explicitly requested persistent coverage for + the README parity checker and for inert-region marker behaviour. That + authorization supersedes the 1600-line ceiling only for these named review + fixes and their tests; it does not raise the eighteen-file cap or authorize + unrelated work. Against merge base `c31057c1`, the final implementation + changes eighteen paths and adds 1774 net lines, excluding this plan. The + named paths are `README.md` and its six translations; + `docs/adr-027-command-placeholder-contract.md`, `docs/contents.md`, + `docs/developers-guide.md`, `docs/formal-verification-methods-in-netsuke.md`, + `docs/netsuke-design.md`, `docs/repository-layout.md`, and `docs/roadmap.md`; + `scripts/check-readme-parity.sh`; and `tests/documentation_examples_tests.rs`, + `tests/readme_security_tests.rs`, and `tests/readme_parity_tests.rs`. The + source tree and dependencies are unchanged. On 2026-09-23 the user had + authorized the remaining three milestones while explicitly identifying the + existing 2445-line diff, including 2075 plan lines; that authorization + superseded the earlier whole-plan 1200-line ceiling for the named work. +- **Production code.** Any change to `src/` beyond the doc-comment correction + named in EP-M3. Immediate stop. +- **Contract disagreement.** If a new test shows the implementation does not + behave as `Context and orientation` states, stop. The plan's facts are wrong, + or the code has a defect; either way a human decides. +- **Interface.** Any change to a public API signature, or to + `EXPECTED_EXAMPLE_IDS` beyond adding the three new identifiers. +- **Dependencies.** Any addition to `Cargo.toml`. Immediate stop. +- **Iterations.** A gate that still fails after three fix attempts. +- **Translation confidence.** If the writer cannot render a security claim in + a target language with confidence that the meaning is preserved, stop at + EP-M4 and escalate rather than shipping an approximate security statement. A + wrong translation of a safety boundary is worse than an absent one. +- **Ambiguity.** Any point where `FV-CPC-Q1`, `FV-CPC-Q2`, or `FV-CPC-Q3` + could reasonably be settled the other way and the choice changes what the + README promises. `D1` carried a live instance of this until ADR-034 settled + it upstream; see `D1-RESOLVED` in `Decision log`. + +## Risks + +- **Risk: roadmap 4.2.3 is marked complete but its execplan header says + `IN PROGRESS`.** `docs/roadmap.md:490-504` checks all six sub-items; + `docs/execplans/4-2-3-kani-harnesses-for-command-interpolation.md:8` reads + `Status: IN PROGRESS`. 4.4.1 declares 4.2.3 a prerequisite. Severity: low. + Likelihood: certain (already observed). Mitigation: the substantive + prerequisite is that the Kani and Proptest harnesses exist and pass, which is + directly verifiable — `src/ir/cmd_interpolate/verification.rs` contains both + proofs and `src/ir/cmd_interpolate_property_tests.rs` contains eleven + properties. The prerequisite is met in substance. Treat the header as a stale + status line, note it in `Surprises & discoveries`, and do **not** edit that + execplan; a completed plan is a historical document. Raise it separately if + desired. + +- **Risk: the README section overstates the guarantee.** A security section + that reads as reassurance rather than as a boundary is actively harmful, + because a manifest author may conclude Netsuke sanitizes their shell text. + Severity: high. Likelihood: medium. Mitigation: the section leads with what + Netsuke does *not* do. Constraint 2 and the `logisphere-design-review` pass + at Stage A both target this. The existing README already says "it is not a + sandbox"; keep that framing and strengthen it. + +- **Risk: executable examples require external tools.** Severity: low. + Likelihood: low. Only the accepted build invokes real Ninja; its isolated + child environment receives the host search path through `mockable::Env`. + Missing Ninja fails this case rather than skipping it. The quoted-path test + inspects generated text, and the rejected example fails during lowering with + an intentionally nonexistent Ninja path. EP-M3 verified all three. + +- **Risk: translated security prose drifts from the English original.** No + gate checks it; the `typos` gate explicitly excludes the six files. Severity: + medium. Likelihood: medium. Mitigation: translate the *structure* + mechanically — same heading, same bullet count, same order, identical code + fences and identifiers — so a reviewer can compare line-for-line without + reading the target language. Consult `docs/localization-glossary.md` for each + locale's established term for "placeholder", "shell", and "quote". Escalate + per the translation tolerance rather than guessing. + +- **Risk: `mdtablefix` reformats unrelated Markdown.** `make check-fmt` + enforces canonical formatting repository-wide, and a stale file can surface + as a spurious failure in this branch. Severity: low. Likelihood: medium. + Mitigation: run `make fmt` early, inspect `git diff --stat`, and if files + outside this plan's scope change, commit that reformatting as a separate, + clearly labelled commit before the substantive work. + +- **Risk: `actionlint` is not on `PATH`, so `make lint` exits 127.** A known + environment issue, not a code defect. Severity: low. Likelihood: medium. + Mitigation: prepend `~/go/bin` to `PATH` before running `make lint`. If it is + genuinely absent, record that `github-actions-lint` did not run and say so in + the evidence rather than reporting a clean gate. + +- **Resolved risk: the ADR number collided before implementation.** The + record was allocated as ADR-027 during EP-M1, and the index and references + use that number. No further record is allocated by the remaining milestones. + +## Verification plan + +This change adds no production code, so it introduces no new invariant over +program inputs, states, or transitions. Stating that and stopping would be a +vacuous discharge of this section. It is not the right answer either, because +the change *does* introduce an obligation of a different kind, and that +obligation is checkable. + +The obligation is **documentation–implementation agreement**: each claim the +README makes about the placeholder contract must be true of the code at the +commit that ships it, and must become false — visibly, in a failing test — if +the code later changes. A README claim with no executable counterpart is an +assertion nobody can falsify, which is exactly the failure mode this section +exists to prevent. + +### Axioms + +Assumptions relied upon, not verified here: + +- `AX-SHLEX`: `shlex` 2.0.1's `split` implements a POSIX-compatible word + split and returns `None` on unterminated quoting or an unterminated escape. + Netsuke's contract is defined *in terms of* this behaviour; the crate's + internals are not this repository's to verify. Recorded in the ADR because it + is precisely why `D3` refuses to commit to the accepted set. +- `AX-NINJA-SH`: on Unix, Ninja passes a rule's `command` string to `sh -c`; + on Windows it passes the string to `CreateProcess`. This is Ninja's + documented behaviour, not Netsuke's, and it is the reason backticks are a + live construct rather than inert text. No Netsuke source names `sh -c`; the + README must therefore attribute this to Ninja, not claim it as Netsuke + behaviour. +- `AX-SHELL-QUOTE`: `shell-quote` 0.7.2 in `Sh` mode emits POSIX-quoted text + that round-trips through a POSIX shell. This is what makes the path-quoting + claim true. +- `AX-HARNESS`: `tests/documentation_examples/mod.rs` faithfully extracts the + fenced text from `README.md`. It is repository-owned and already + self-testing, so a test that runs an extracted example genuinely exercises + the published prose rather than a copy. + +### Obligations + +**`OBL-PLACEHOLDERS` — the documented placeholder set is the accepted set.** + +- Statement: a manifest using `{{ ins }}` and `{{ outs }}` in a `command:`, + alongside a literal `$PATH`, compiles and builds; the `$PATH` reaches the + shell unrewritten. +- Method: parameterized integration test over the documented example, using + `rstest` with `googletest` matchers and `pretty_assertions`. +- Rationale: the accepted set is finite and enumerable. Property testing over + generated templates already exists upstream + (`src/ir/cmd_interpolate_property_tests.rs`); duplicating it here would add + cost without adding evidence. What is *not* yet covered is that the README's + specific published text behaves as the README says, and that is an + exact-example obligation. +- Domain: the three fenced examples in the new README section, extracted at + run time by `documented_example`. +- Artefact: `tests/readme_security_tests.rs`, case + `documented_safe_placeholder_manifest_builds`. +- Evidence: + `NETSUKE_REQUIRE_NINJA=1 cargo nextest run --test readme_security_tests`. Red + before the README section exists, because `documented_example` returns an + error whose context is + `documented example 'readme-safe-placeholder-manifest' should exist`. Green + once the section and the registry entry are added. Discharged when the test + passes and `every_documented_fence_has_a_known_unique_identifier` + (`tests/documentation_examples_tests.rs:143`) also passes. + + The environment variable is load-bearing. The `ninja` guard at + `tests/documentation_examples_e2e_tests.rs:52` is a silent `return Ok(())`, + but `test_support/src/ninja.rs:73-89` escalates to a panic when + `NETSUKE_REQUIRE_NINJA=1`, which `.github/workflows/ci.yml:78` sets. A green + local `make test` without it is therefore not evidence that this obligation + was exercised. +- Non-vacuity: the test asserts on observable output, not merely on a + non-error exit. It requires that the built artefact contains the expected + text and that the generated Ninja command still contains a literal `$PATH` + after Netsuke's `$` → `$$` Ninja escaping is reversed. + + The obvious negative control — change `{{ ins }}` to `{{ inputs }}` and + expect failure — is **rejected as non-discriminating**. + `src/manifest/mod.rs:128` sets `UndefinedBehavior::Strict`, so an undefined + variable fails at stage 1 inside MiniJinja, before `find_substitution` is + ever reached. That control fires identically against an implementation + recognizing a completely different marker set, so it proves only that + MiniJinja is strict. + + The discriminating control is to put a literal `$in` in the example and + require it to survive into the generated Ninja verbatim, as `$$in`. That + fails only if something starts rewriting a dollar-prefixed form, which is the + claim actually at issue. Run it once by hand, record the transcript in + `Artefacts and notes`, and do not commit it. + +**`OBL-UNIFORM-SET` — the placeholder set is the same in both recipe kinds.** + +- Statement: `{{ ins }}` and `{{ outs }}` resolve to paths in both `command:` + and `script:` recipes, and `$in`, `$out`, `$ins`, `$outs`, `$input`, and + `$output` survive as shell variables in both, reaching the generated Ninja + with their dollar doubled. +- Method: parameterized `rstest` over the (form, recipe kind) matrix, + asserting on generated Ninja text. +- Rationale: this obligation replaces `OBL-RECIPE-KIND`, which asserted the + opposite. ADR-034 made the set uniform, and the README's central table now + claims that uniformity. A uniform rule is *easier* to state and *easier* to + regress silently: reinstating a special case for one form in one recipe kind + would pass every existing README example. The matrix is what makes the + README's table falsifiable. +- Domain: eighteen cells: the two markers, all six dollar forms in the + table, and `$PATH`, each crossed with `command:` and `script:`. +- Artefact: `tests/readme_security_tests.rs`, case + `dollar_forms_are_shell_variables_in_both_recipe_kinds`. +- Evidence: `cargo nextest run --test readme_security_tests`. Discharged when + every cell matches the README's table. +- Non-vacuity: the marker rows and the shell-variable rows are asserted + together, so the test fails against an implementation that rewrote nothing + **and** against one that rewrote a dollar form in either recipe kind. A + shell-variable-only test would pass against an implementation that had + stopped resolving the markers too. As a seeded fault, restore a + dollar-placeholder match inside `find_script_substitution` + (`src/ir/cmd_interpolate/mod.rs`) so `$in` resolves in a script again; the + `script:` shell-variable rows must fail. This is the pre-ADR-034 behaviour, + so the control also demonstrates that the suite would have caught the + reversal. Revert immediately. + +**`OBL-QUOTING` — Netsuke's own path substitutions are shell-quoted.** + +- Statement: an input path containing a space is substituted into a `command:` + in a form the shell reads as a single argument. +- Method: exact-example integration test asserting on generated Ninja text. +- Rationale: this is the section's only *positive* security promise; every + other claim is a boundary or a non-guarantee. An unasserted positive security + promise is the worst kind of documentation debt, because a reader acts on it. + Absent from revision 1; added at design review. +- Domain: one input path containing a space, on the POSIX route. +- Artefact: `tests/readme_security_tests.rs`, case + `netsuke_owned_path_substitutions_are_quoted`. +- Evidence: `cargo nextest run --test readme_security_tests`. Discharged when + the generated command contains the quoted form `shell-quote` produces rather + than the bare path. +- Non-vacuity: the assertion is on the quoted form specifically, not merely on + the path appearing somewhere, so it fails against an implementation that + interpolated the path unquoted. `tests/command_escaping_tests.rs` is adjacent + but asserts through `shlex::split` word counts rather than pinning the + README's wording; this case pins the wording. + +**`OBL-BACKTICK-REJECT` — the documented backtick boundary is the enforced +boundary.** + +- Statement: a manifest whose `command:` places `{{ ins }}` inside a backtick + pair is rejected during IR lowering with the diagnostic + `Invalid command interpolation:`, before Ninja is invoked. +- Method: parameterized integration test with a negative control, plus a + mutation check reusing the repository's existing mutation artefact. +- Rationale: this is the single claim in the section most likely to be read as + a stronger guarantee than it is. It needs a test that fails loudly if + rejection is ever relaxed, and prose that a reader cannot mistake for general + injection defence. +- Domain: the documented rejecting example, plus two handwritten controls in + the same test file that are *not* in the README: a balanced backtick pair + containing only author text, which must be **accepted** (proving the guard is + narrow, exactly as documented); and an odd count of backticks with no marker + at all, which must be **rejected** (proving the parity check is whole-string, + exactly as documented). +- Artefact: `tests/readme_security_tests.rs`, cases + `documented_backtick_manifest_is_rejected`, + `balanced_author_backticks_are_accepted`, + `odd_backtick_count_without_markers_is_rejected`. +- Evidence: `cargo nextest run --test readme_security_tests`. Red before the + README section exists, for the same missing-identifier reason. Discharged + when all three pass and the diagnostic text matches + `locales/en-GB/messages.ftl:188`. +- Non-vacuity: the two control cases are the whole point. A test suite that + only checked rejection would pass equally well against an implementation that + rejected *every* backtick, which is not what the README will claim. The + accepting control fails against such an implementation, so the pair pins the + boundary from both sides. As a seeded fault, apply the existing mutation patch + `docs/verification/mutations/ir__cmd_interpolate__property_tests__substituted_odd_backticks_are_rejected.patch`, + which flips `has_unmatched_backticks`'s parity test from `!= 0` to `== 0`, + and confirm `odd_backtick_count_without_markers_is_rejected` fails under it. + Revert the patch afterwards. Record the transcript. + +**`OBL-SHLEX-SCOPE` — the `shlex` gate applies to commands, not scripts.** + +- Statement: text that `shlex::split` cannot split is rejected in a + `command:` and accepted in a `script:`. +- Method: parameterized integration test over both recipe kinds with the same + offending text. +- Rationale: this asymmetry is `RM-4.4.1.d`'s substance. Note that it survives + ADR-034 untouched: the *placeholder set* became uniform, but the `shlex` and + backtick-parity *guards* remain `command:`-only, so this is now the only + recipe-kind asymmetry the README must describe. A single example per branch + is decisive because the two entry points are distinct functions with no + shared guard — `interpolate_script_with_bindings` calls `substitute_script` + and nothing else (`src/ir/cmd_interpolate/mod.rs`). +- Domain: one unterminated single quote, used as a `command:` and as a + `script:`. +- Artefact: `tests/readme_security_tests.rs`, case + `shlex_gate_applies_to_commands_not_scripts`. +- Evidence: `cargo nextest run --test readme_security_tests`. Discharged when + the command form errors and the script form generates successfully. +- Non-vacuity: both arms must be asserted. Asserting only the rejection would + pass against an implementation that gated both, which would contradict the + README. The offending text must be chosen so that `shlex::split` genuinely + returns `None` — verify by running `cargo test --doc` on a scratch snippet, + or by observing the diagnostic — and not merely so the manifest is invalid + for some unrelated reason. Assert on the specific `ir.invalid_command` text, + not on exit status alone. + +**`OBL-STRUCTURAL-PARITY` — the README family stays in structural lockstep.** + +- Statement: at the end of EP-M4, the seven README files have identical + heading counts, levels, and order. +- Method: a mechanical check, run and recorded, not a committed test. +- Rationale: a committed parity test is tempting but would be a new + repository-wide policy gate, which exceeds this item's mandate and would fire + on unrelated future work. The convention is documented in + `docs/repository-layout.md`; enforcing it in CI is a separate decision. If a + reviewer asks for the gate, escalate rather than adding it silently. +- Artefact: the command in `Concrete steps` step 9. +- Evidence: the command prints `7` identical counts and a matching level + sequence. +- Non-vacuity: revision 1 claimed the check "compares the level sequence, not + just the count". It did not. `tr -d ' \n'` collapsed the levels into one + undelimited run of `#` characters, so `## A / ### B` and `### A / ## B` were + indistinguishable and the second command was a character count redundant with + the first. The corrected command in `Concrete steps` step 8 delimits each + heading with `paste -sd,`, so a reordering or a level change is caught. This + correction came from the design review. + +**No obligation is claimed for the correctness of `src/ir/cmd_interpolate/` +itself.** That is discharged upstream by roadmap 4.2.3's Kani proofs and +Proptest properties, and this plan must not restate them as though they were +new evidence. + +## Milestones and plateaus + +### EP-M1 — settled contract, recorded + +- Outcome: `docs/adr-027-command-placeholder-contract.md` exists and records + decisions `D1`, `D2`, and `D3`. `docs/contents.md` lists it under + `## Decision records`. `docs/netsuke-design.md` references it from the + command-lowering discussion near line 2616. No other file changes. +- Requirements advanced: `FV-CPC-Q1`, `FV-CPC-Q2`, `FV-CPC-Q3`; prerequisites + for `RM-4.4.1.b`, `.c`, `.d`. +- Acceptance evidence: `make check-fmt` and `make markdownlint` pass. The ADR + contains a Status, Date, and Context and Problem Statement section in that + order, per `docs/documentation-style-guide.md:374-384`. +- Conformance check: the ADR states no guarantee the code does not provide; + each of its three decisions cites the implementing file and line; it does not + describe the backtick or `shlex` guards as proved (Constraint 7). +- Recovery: the ADR is a new file and two small insertions. Revert with + `git revert` or delete the file and drop the two index lines. +- Remaining gaps: the README still says nothing. +- Compatibility decision: none. A new document has no consumers. + +### EP-M2 — internal documents corrected + +This milestone preceded the README so linked documents would agree. Its +original script-only corrections are historical: ADR-034 removed that behaviour +before EP-M3. The authoritative post-rebase outcome is recorded in `Progress`: +the developers' guide, formal-verification document, and design agree with the +uniform set; the users' guide and production module match main. + +- Outcome: internal documentation states that only `{{ ins }}` and + `{{ outs }}` are markers in both recipe kinds. It distinguishes the shared + marker protection from the command-only parity and `shlex` checks. The + formal-verification document answers `FV-CPC-Q1` through `FV-CPC-Q3`, cites + ADR-027, and corrects the `[^8]` module path. +- Requirements advanced: Fact A consistency across the documentation set; + prerequisite for `RM-4.4.1.a`, because the README will link to + `docs/users-guide.md#review-the-safety-boundary`. After ADR-034 this + milestone also reconciles the branch's own earlier corrections, which had + documented the now-removed asymmetry. +- Acceptance evidence: the scoped grep in `Concrete steps` step 5 returns no + unqualified claim outside `docs/archive/` and the ADR set. `make check-fmt`, + `make markdownlint`, `make lint`, and `make test` pass — `make lint` matters + because a doc-comment edit recompiles. +- Conformance check: no behaviour changed; `git diff --stat src/` shows only + comment lines; the corrected statements agree with each other, with + `docs/users-guide.md` as `main` now writes it, and with ADR-034; no + historical document (`docs/archive/`, any `docs/adr-0*.md` other than the + branch's own ADR-027, any completed execplan) was edited. +- Recovery: revert the commit. The edits are independent of every later + milestone. +- Remaining gaps: the README still says nothing; six translations lack the + section. +- Compatibility decision: none. + +### EP-M3 — the README states the contract, executably + +- Outcome: `README.md` carries a new `## Security and command interpolation` + section, placed after `## What works today` and before + `## Release and development status`, delimited by the repository's + seventy-underscore thematic breaks. Its internal parts are **bold labels, not + `###` headings**, so the file's heading count rises from twelve to exactly + thirteen and the translations do not each gain six headings. It contains + three marked, executable fenced examples. + `tests/documentation_examples_tests.rs` registers the three new identifiers. + `tests/readme_security_tests.rs` exists and discharges `OBL-PLACEHOLDERS`, + `OBL-UNIFORM-SET`, `OBL-QUOTING`, `OBL-BACKTICK-REJECT`, and + `OBL-SHLEX-SCOPE`. The existing safety paragraph in + `## Release and development status` (`README.md:203-206`) is reduced to a + one-line cross-reference that **retains its link** to the users' guide safety + boundary, so the two do not contradict each other and no link is lost. +- Requirements discharged: `RM-4.4.1.a`, `.b`, `.c`, `.d`. +- Acceptance evidence: `make check-fmt`, `make markdownlint`, `make lint`, and + `NETSUKE_REQUIRE_NINJA=1 make test` all pass. Every new test fails before the + README section exists and passes after. All three negative controls behave as + `Verification plan` requires. +- Conformance check: every claim traces to a cited line or a new test; the + live hazard appears before the placeholder table, not after it; the sentence + "a backtick pair you wrote is executed by the shell" appears above the first + fence; `D1`-`D3` in ADR-027 and the README prose agree word-for-word on the + three contested points; the section carries a pre-1.0 stability caveat + because it sits above the release-status hedge. +- Recovery: revert the commit. `README.md` and the two test files are the only + changes; nothing else depends on them. +- Remaining gaps: six translations lack the section. +- Compatibility decision: none. `EXPECTED_EXAMPLE_IDS` is a test-only, + crate-internal constant; adding to it needs no shim. + +### EP-M4 — the translated READMEs regain parity + +- Outcome: all six translated READMEs carry the equivalent section in the same + position, with the same heading level, the same bullet order, and byte- + identical code fences. `README.md` is unchanged by this milestone. +- Requirements discharged: `RM-4.4.1.a` across the README family; + `OBL-STRUCTURAL-PARITY`. +- Acceptance evidence: the parity command in `Concrete steps` step 9 reports + identical heading structures across all seven files. `make check-fmt` and + `make markdownlint` pass. The translated files remain outside the `typos` + scope, so the spelling gate is unaffected. +- Conformance check: no translated file gained a `tested-example` marker; + terminology matches `docs/localization-glossary.md` for each locale; no + security claim was softened or strengthened in translation. +- Recovery: revert the commit. English parity is restored trivially because + EP-M3 did not depend on the translations. +- Remaining gaps: the roadmap entry is still open. +- Compatibility decision: none. + +### EP-M5 — roadmap closed and gates green + +- Outcome: `docs/roadmap.md` item 4.4.1 and its four sub-items are marked + `[x]`, with a short completion note in the repository's established style + recording that the contract was settled in ADR-027 and that translations + landed. All gates pass on the full branch. +- Requirements discharged: `RM-4.4.1`. +- Acceptance evidence: the full gate sequence in `Concrete steps` step 10, + with each log file retained. +- Conformance check: reconcile every entry in `Surprises & discoveries` + against the upstream artefacts before setting this plan to `COMPLETE`, per the + `execplans` skill's reconciliation rule. +- Recovery: the roadmap edit is a four-line change; revert independently. +- Remaining gaps: none for `RM-4.4.1`. The 4.2.3 execplan status discrepancy + is deliberately left for a separate decision. +- Compatibility decision: none. + +## Plan of work + +### Stage A — settle the three contract questions (no file changes) + +Re-verify Facts A, B, and C against the code before writing anything. Do not +take this plan's citations on trust; open each cited line. If any fact is +wrong, stop — a `Constraints` item 2 conflict. + +Then settle the three questions. These answers were revised at the +`logisphere-design-review` checkpoint; the wording below is what the README and +ADR-027 must say. + +- **`D1` (answers `FV-CPC-Q1`).** `{{ ins }}` and `{{ outs }}` are the only + Netsuke markers, and they behave identically in `command:` and `script:` + recipes. Every dollar-prefixed form — `$in`, `$out`, `$ins`, `$outs`, + `$input`, `$output`, `$PATH` — is a shell variable that Netsuke leaves for + the selected shell, in both recipe kinds; the Ninja backend doubles its + dollar so the shell receives it unchanged. Netsuke does not expose Ninja's own + `$in` / `$out` rule variables, because it bakes resolved paths into each + content-hashed rule. + + Revisions 1 and 2 answered this question differently, describing a + `script:`-only rewriting of `$in` and `$out` as retained legacy behaviour. + That asymmetry existed when those revisions were written and was removed + upstream by ADR-034 before this plan reached implementation. `D1` now simply + states the uniform rule; the shadowing hazard and the migration steer the + earlier wording carried are no longer applicable. ADR-034 owns the decision + and ADR-027 states the resulting contract. + +- **`D2` (answers `FV-CPC-Q2`).** Two separate mechanisms, promised at two + different strengths. Revision 1 promised both as guarantees; the review + showed that freezes a whole-string parity approximation alongside a genuine + invariant, such that a future quoting-aware fix would be a breaking change. + The revised wording: + + - **Invariant, promised.** A Netsuke placeholder is never substituted inside + a backtick or `$( … )` region; such a recipe is rejected. This is policy + and will hold. + - **Conservative check, not promised.** A `command:` recipe whose + substituted text contains an odd total number of backtick characters is + *currently* rejected. The check is not quoting-aware — it counts backticks + inside single quotes too — so it may reject valid shell text such as + `echo 'a` followed by a backtick and a closing quote. A future release may + accept more, and such widening is **not** treated as a breaking change. + - **Not a guarantee at all.** Netsuke leaves author-written backticks + untouched. On POSIX routes, active backticks can trigger command + substitution; backticks inside single quotes remain literal. + - **Route scope.** PowerShell is outside the backtick half of the invariant + and outside the parity check, because its backtick is an escape character, + but it is **inside** the `$( … )` half: PowerShell rejects a marker in a + command substitution just as POSIX does, by a different rule. The parity + check is also `command:`-only; a `script:` recipe gets the marker invariant + but no parity check. State both axes, because a reader who moves a recipe + to `script:` otherwise loses a check silently, and a reader who switches + backends needs to know that `$( … )` protection survives the switch. The + README's `D2` prose must not say PowerShell is exempt from the marker + invariant *tout court*. + +- **`D3` (answers `FV-CPC-Q3`).** `shlex::split` **is** part of the acceptance + contract in the observable sense: a `command:` recipe on the POSIX or Bash + route whose substituted text cannot be split is rejected, with a stable, + localized diagnostic, at IR-lowering time. It does not apply to `script:` + recipes or to PowerShell, and Netsuke never executes the tokens it produces. + + It is **not** a stability commitment about the precise accepted set. That set + is fixed by the `shlex` version resolved when the binary was built, and + `Cargo.toml:138` is a caret requirement rather than a pin (`AX-SHLEX`). Name + both drift directions explicitly, because they are not symmetric: + + - a future `shlex` that accepts text today's rejects means previously + rejected manifests begin to build — not treated as a breaking change; + - a future `shlex` that rejects text today's accepts means a working + manifest stops building — a defect, not a policy change, and recorded in + the changelog. + + The README must also say what passing the gate does *not* mean: `shlex` + performs no expansion and treats a backtick as an ordinary character, so a + command that splits cleanly is not thereby safe. Downstream tooling must not + reuse `shlex::split(x).is_some()` as an injection check. + +Go/no-go: the `logisphere-design-review` pass has run and its findings are +folded into revision 2. Before writing prose, re-read `D1`-`D3` once and +confirm that no sentence could be read as a guarantee Netsuke does not make. + +### Stage B — red + +Add `tests/readme_security_tests.rs` with the seven cases named in +`Verification plan`, referencing the three documented-example identifiers that +do not yet exist. It must declare `mod documentation_examples;` after the +module documentation and any crate attributes — that declaration is +load-bearing beyond the import, because +`tests/integration_test_wiring_tests.rs` enforces both that module trees are +wired to Cargo test targets (`:150`) and that Cargo discovers every top-level +integration source (`:186`). No `[[test]]` entry is needed: `Cargo.toml` sets no +`autotests = false`, so autodiscovery covers the new file. + +Run the suite and observe each case failing with +`documented example '…' should exist`. This is the red stage and it must be +observed, not assumed. Do not use an expected-failure marker; Rust has no strict +`xfail`, and the missing-identifier error is already specific enough to prove +the test fails for the intended reason. + +Also add the three identifiers to `EXPECTED_EXAMPLE_IDS` *before* adding the +README fences, and observe +`every_documented_fence_has_a_known_unique_identifier` +(`tests/documentation_examples_tests.rs:143`) fail with a registry-drift +message naming exactly the three missing identifiers. That is a second, sharper +red signal. Revision 1 named this test +`documented_example_registry_is_exhaustive`, which does not exist. + +### Stage C — green + +Write the README section (EP-M3). Its parts are **bold labels, not `###` +headings**, so the file gains exactly one heading and each translation does not +gain six. The order below was changed at design review: revision 1 put the +placeholder table second and the backtick hazard fifth, so a reader who stopped +halfway came away with a capability list and no warning. The live hazards now +come first. + +1. **Framing.** A `Netsukefile` executes commands, so it warrants the same care + as a `Makefile`. Netsuke narrows some quoting mistakes but is not a sandbox. + This paragraph must contain the sentence "a backtick pair you wrote is + executed by the shell", and it must appear above the first fence. +2. **What Netsuke does not protect you from.** Author-written backticks and + `$( … )`; and, under its own bold label, **"Values you template in are not + quoted"** — arbitrary Jinja output, `raw` blocks, and handwritten shell + fragments are indistinguishable from typed text by the time Netsuke sees + them. This is the actual injection vector, and revision 1 buried it in a + list of things Netsuke "leaves alone" alongside the entirely benign `$PATH`, + which reads as a capability list rather than a warning. Give it a label a + reader cannot skim past. +3. **What Netsuke rewrites.** The `D1` contract as a compact table: rows for + `{{ ins }}`, `{{ outs }}`, and the inert `$in`/`$out`/`$ins`/`$outs`/ + `$input`/`$output` group; columns for `command:` and `script:`. Cells are + code identifiers and yes/no, which is what survives translation intact. + Every cell in the dollar-form row reads "no" in both columns — state that + uniformity in a sentence as well as in the table, because a reader scanning + only the table may assume a column was omitted by mistake. The accepting + example fence follows. +4. **What Netsuke quotes.** Only its own path substitutions, via + `shell-quote`, encoded for the surrounding quote context. Nothing else. The + quoting example fence follows. +5. **Backticks and command substitution.** The `D2` boundary at its three + stated strengths — invariant, conservative check, no guarantee at all — with + the rejecting example fence and the exact diagnostic text. +6. **The `shlex` guard.** The `D3` statement: the routes and recipe kinds it + covers, both drift directions, and the explicit warning that passing the + gate does not make a command safe. +7. **Stability and further reading.** A pre-1.0 caveat, because this section + sits *above* `## Release and development status` and would otherwise make + promises before the reader meets the hedge at `README.md:179-186`. Then + pointers to `docs/users-guide.md#review-the-safety-boundary` and ADR-027. + +Keep the split with the users' guide deliberate. The README owns the +placeholder contract and the non-guarantees; the users' guide keeps shell-route +mechanics such as command-list chaining, `exec`, and background jobs, and the +README does not restate them. + +Then reduce the existing safety paragraph at `README.md:203-206` to a single +cross-referencing sentence that **keeps its link** to the users' guide safety +boundary. + +Run the suite again and observe green. + +### Stage D — correct, translate, refactor, and validate + +EP-M2, then EP-M4, then EP-M5. Each is a separate commit. After each, run the +gates listed for that milestone. Re-read the whole new section once with fresh +eyes before EP-M4, because a translation multiplies any wording defect by six. + +## Concrete steps + +Run everything from the repository root, +`/home/leynos/.lody/repos/github---leynos---netsuke/worktrees/a52242fa-b09a-4ab9-8c9e-687ca8533644`. + +1. Confirm the branch and freshness. + + ```sh + git branch --show-current + git fetch origin + git log --oneline -1 origin/main + ``` + + Expect `4-4-1-document-command-placeholder-contract-in-readme` and a commit + at or behind the branch point. + +2. Re-verify the three facts. + + ```sh + sed -n '150,235p' src/ir/cmd_interpolate/mod.rs + sed -n '255,300p' src/ir/cmd_interpolate/mod.rs + grep -n 'ir.invalid_command' locales/en-GB/messages.ftl + ``` + + Expect `has_unmatched_backticks` to be a `rem_euclid(2) != 0` parity test, + `is_valid_command_for_shell` to return early for `RecipeShell::PowerShell`, + `find_script_substitution` to delegate to `find_substitution` with no + dollar-form matching of its own, and the Fluent line + `ir.invalid_command = Invalid command interpolation: { $snippet }.` + + The third expectation changed after ADR-034. If + `try_match_dollar_placeholder` reappears, the upstream decision has been + reversed again: stop and escalate rather than editing this plan around it. + +3. Confirm the next free ADR number. + + ```sh + ls docs/adr-*.md | sed 's/.*adr-0*\([0-9]*\).*/\1/' | sort -n | tail -1 + ``` + + This was `20` in revision 2. On the rebased tree it is `26`; PR 621, an + unmerged and stale branch, also holds a file named + `docs/adr-021-manifest-linting-under-netsuke-check.md`, so 021 would collide + on merge even though main has nothing at that number. The ADR is therefore + **027**, and every reference in this plan was updated. Step 4 is complete; + re-running this step now would find `27` and must not mint a second record. + +4. Write `docs/adr-027-command-placeholder-contract.md`, add its entry to + `docs/contents.md` under `## Decision records` immediately below the ADR-026 + entry, and add the design-document reference. Commit as EP-M1. **Done** as + `3594b568`. + + ```bash + set -euo pipefail + make check-fmt 2>&1 | tee /tmp/check-fmt-netsuke-$(git branch --show-current).out + make markdownlint 2>&1 | tee /tmp/markdownlint-netsuke-$(git branch --show-current).out + ``` + +5. Correct the three internal documents and any inaccurate doc comment, then + confirm no unqualified claim survives outside historical documents. Commit + as EP-M2. This precedes the README so the repository is never in a state + where two normative documents disagree. **Done** — see `Progress`. + + The verification grep must be line-wrap-agnostic, because the claim that + matters in `docs/users-guide.md` wraps mid-phrase. Plain `grep` cannot match + across lines and silently misses it, which is the trap that let the + misstatement survive revision 1. Use `rg -U`: + + ```sh + rg -n -U --glob 'docs/*.md' \ + -e 'only\s+Netsuke\s+markers' \ + -e '\$in.{0,80}remain\s+shell\s+variables' \ + README.md docs/ \ + | rg -v '^docs/(adr-|archive/|execplans/)' + ``` + + Run from the repository root. The `--glob` restricts the `docs/` walk to + top-level documents, and the trailing filter drops the decision records, + archived plans, and execplans, none of which this plan may edit. The only + expected survivors are statements about `$ins` and `$outs`, which really are + literal shell variables in both recipe kinds. After ADR-034 the same is true + of `$in` and `$out`, so a hit stating that any dollar-prefixed form is a + shell variable in both recipe kinds is correct and needs no change. A hit + that still qualifies `$in` or `$out` by recipe kind is stale and must be + corrected. + + For the record, the original wording of this step was + `grep -rn -e 'only Netsuke markers' -e 'remain unchanged' -e 'remain shell + variables' -e 'remain literal shell variables'`. + Three things were wrong with it. It cannot match `docs/users-guide.md`'s + claim, which wraps across a line break. Its `remain unchanged` pattern is + far too broad, matching unrelated prose about schema and snapshot stability + in `docs/roadmap.md` and `docs/developers-guide.md`. And revision 1's + narrower variant (`'literal .\$in\|\$in. and .\$out. remain'`) hit + `docs/adr-004-…:165`, which must not be edited. + +6. Add `tests/readme_security_tests.rs` and the three identifiers to + `EXPECTED_EXAMPLE_IDS`. Observe red. + + ```bash + set -euo pipefail + if cargo nextest run --test readme_security_tests --test documentation_examples_tests \ + 2>&1 | tee /tmp/red-netsuke-$(git branch --show-current).out; then + printf '%s\n' 'Expected the missing-example tests to fail' >&2 + exit 1 + fi + ``` + + Expect failures naming `readme-safe-placeholder-manifest`, + `readme-quoted-path-manifest`, and `readme-backtick-rejection-manifest`, + plus a registry-drift failure from + `every_documented_fence_has_a_known_unique_identifier` listing exactly those + three. Record the transcript in `Artefacts and notes`. + +7. Write the README section with all three marked fences. Observe green, then + run the full gates and commit as EP-M3. + + ```bash + set -euo pipefail + NETSUKE_REQUIRE_NINJA=1 cargo nextest run \ + --test readme_security_tests --test documentation_examples_tests \ + 2>&1 | tee /tmp/green-netsuke-$(git branch --show-current).out + ``` + + `NETSUKE_REQUIRE_NINJA=1` is required. Without it the `ninja` guard at + `tests/documentation_examples_e2e_tests.rs:52` returns `Ok(())` silently, so + a green local run is not evidence that `OBL-PLACEHOLDERS` was exercised. + Continuous integration sets it at `.github/workflows/ci.yml:78`. + +8. **Only now** run the three negative controls, and record each transcript. + Revision 1 placed these before the commit and restored with + `git checkout -- README.md`, which would have deleted the entire + newly-written section, because HEAD has no such section. Commit first; + restore by copy, never by `git checkout`. + + ```sh + MUT=docs/verification/mutations/ir__cmd_interpolate__property_tests__substituted_odd_backticks_are_rejected.patch + + # Control 1 (OBL-PLACEHOLDERS): a literal $in in the *command* example must + # survive verbatim into the generated Ninja as $$in. + cp README.md /tmp/readme-control.md + # ...edit README.md, then: + cargo nextest run --test readme_security_tests + cp /tmp/readme-control.md README.md + + # Control 2 (OBL-UNIFORM-SET): make find_script_substitution resolve a bare + # $in again, reinstating the pre-ADR-034 behaviour; the script rows must + # fail. This also shows the suite would have caught that reversal. + cp src/ir/cmd_interpolate/mod.rs /tmp/cmd-interpolate-control.rs + # ...edit, then: + cargo nextest run --test readme_security_tests + cp /tmp/cmd-interpolate-control.rs src/ir/cmd_interpolate/mod.rs + + # Control 3 (OBL-BACKTICK-REJECT): seed the stored parity fault. + git apply "$MUT" + cargo nextest run --test readme_security_tests + git apply -R "$MUT" + git status --porcelain + ``` + + Use `git status --porcelain`, not `git diff`, to confirm the tree is clean + between controls: this repository sets `diff.external=difft`, so `git diff` + output is a rendering rather than a machine signal. `git apply` itself is + unaffected, being content-addressed. Two further nets catch a forgotten + revert — the existing property test, and + `tests/kani_mutation_evidence_tests.rs:9-10`, which re-runs + `git apply --check` on every stored patch. + +9. Translate into the six READMEs, add the recurring-obligation bullet to + `docs/repository-layout.md`, then check parity. Commit as EP-M4. + + ```sh + for f in README.md README.de.md README.es.md README.fr.md \ + README.ja.md README.pt-BR.md README.zh-CN.md; do + printf '%s ' "$f" + grep -c '^#\{1,6\} ' "$f" + done + for f in README.md README.de.md README.es.md README.fr.md \ + README.ja.md README.pt-BR.md README.zh-CN.md; do + printf '%s: ' "$f" + grep -o '^#\{1,6\} ' "$f" | tr -d ' ' | paste -sd, + done + ``` + + Expect `13` for every file — they are 12 today — and an identical + comma-delimited level sequence on every line. The delimiter matters: + revision 1 used `tr -d ' \n'`, which collapsed the levels into one + undelimited run of `#` characters and so could not distinguish + `## A / ### B` from `### A / ## B`. + + Translate the table's column headers and the surrounding prose; leave the + table's cells in English. Cells are code identifiers and yes/no, so a + mistranslated cell becomes structurally impossible and a reviewer with no + target-language reading can diff the table. + +10. Mark roadmap 4.4.1 done and run the full gate sequence. Commit as EP-M5. + Delegate this run to the `scrutineer` sub-agent rather than running it in + the planning context. + + ```bash + set -euo pipefail + make check-fmt 2>&1 | tee /tmp/check-fmt-netsuke-$(git branch --show-current).out + make typecheck 2>&1 | tee /tmp/typecheck-netsuke-$(git branch --show-current).out + PATH="$HOME/go/bin:$PATH" make lint 2>&1 | tee /tmp/lint-netsuke-$(git branch --show-current).out + make doc-coverage 2>&1 | tee /tmp/doc-coverage-netsuke-$(git branch --show-current).out + NETSUKE_REQUIRE_NINJA=1 make test 2>&1 | tee /tmp/test-netsuke-$(git branch --show-current).out + make markdownlint 2>&1 | tee /tmp/markdownlint-netsuke-$(git branch --show-current).out + make nixie 2>&1 | tee /tmp/nixie-netsuke-$(git branch --show-current).out + ``` + +## Validation and acceptance + +A reader can verify the outcome without reading any test: + +- Open `README.md`. Between `## What works today` and + `## Release and development status` there is a + `## Security and command interpolation` section. It names `{{ ins }}` and + `{{ outs }}` as markers, and dollar-prefixed forms as shell variables in both + recipe kinds. It states that author-written backticks can execute commands + when active as POSIX command substitution, without implying that literal + backticks inside single quotes execute. It says which recipe kinds and which + shells the `shlex` gate covers. +- Copy the section's rejected example into a `Netsukefile` and run + `netsuke --json --locale en-GB`. The output contains + `Invalid command interpolation:` and no build runs. +- Copy the section's accepted example and run `netsuke`. It builds. +- Open any translated README. The same section is in the same place at the + same heading level. + +Quality criteria — what "done" means: + +- Tests: `NETSUKE_REQUIRE_NINJA=1 make test` passes. All seven cases in + `tests/readme_security_tests.rs` pass, and each failed before the README + section existed: `documented_safe_placeholder_manifest_builds`, + `dollar_forms_are_shell_variables_in_both_recipe_kinds`, + `netsuke_owned_path_substitutions_are_quoted`, + `documented_backtick_manifest_is_rejected`, + `balanced_author_backticks_are_accepted`, + `odd_backtick_count_without_markers_is_rejected`, and + `shlex_gate_applies_to_commands_not_scripts`. +- Verification: all five obligations are discharged — + `OBL-PLACEHOLDERS`, `OBL-UNIFORM-SET`, `OBL-QUOTING`, `OBL-BACKTICK-REJECT`, + and `OBL-SHLEX-SCOPE` — with the three negative-control transcripts from + `Concrete steps` step 8 recorded. `OBL-STRUCTURAL-PARITY` is discharged by + the step-9 output. +- Lint and typecheck: `make check-fmt`, `make typecheck`, `make lint`, + `make markdownlint`, and `make nixie` all pass. If `actionlint` is missing, + say so explicitly rather than reporting `make lint` clean. +- Performance: no threshold. The new tests add one Ninja-dependent case; with + `NETSUKE_REQUIRE_NINJA=1` set, an absent `ninja` is a failure rather than a + silent skip. +- Security: the README section must not claim any protection the code does not + provide. This is checked by the Stage A design review and re-checked at the + EP-M3 conformance check. + +Quality method: the gate sequence in `Concrete steps` step 10, delegated to +`scrutineer`, with each log retained under `/tmp` for inspection on failure. + +## Idempotence and recovery + +Every step is re-runnable. The gates are read-only except `make fmt`, which +rewrites Markdown formatting in place and is safe to repeat. The three negative +controls mutate the working tree and each is paired with an explicit revert; +run them one at a time and confirm `git status` is clean between them. + +Each milestone is its own commit, so `git revert` restores a coherent state at +any plateau. Do not use bare `git stash` in this worktree: the stash stack is +shared with other checkouts. Set work aside with a temporary work-in-progress +commit instead. + +If `make check-fmt` fails on files this plan did not touch, run `make fmt`, +inspect `git diff --stat`, and commit the unrelated reformatting separately +before continuing. + +## Interfaces and dependencies + +No production interface changes. No dependency changes. + +New test file `tests/readme_security_tests.rs`. It must reuse the existing +harness rather than re-reading `README.md` itself: + +```rust +mod documentation_examples; + +use documentation_examples::{documented_example, manifest_workspace}; +``` + +Its module declaration must include `mod documentation_examples;`. That is +load-bearing beyond the import: `tests/integration_test_wiring_tests.rs` +enforces both that module trees are wired to Cargo test targets (`:150`) and +that Cargo discovers every top-level integration source (`:186`). No `[[test]]` +entry is required — `Cargo.toml` sets no `autotests = false`, so autodiscovery +covers the file. + +The cases it must define, by name: + +- `documented_safe_placeholder_manifest_builds` +- `dollar_forms_are_shell_variables_in_both_recipe_kinds` +- `netsuke_owned_path_substitutions_are_quoted` +- `documented_backtick_manifest_is_rejected` +- `balanced_author_backticks_are_accepted` +- `odd_backtick_count_without_markers_is_rejected` +- `shlex_gate_applies_to_commands_not_scripts` + +Decide deliberately whether the file is Unix-only. Its sibling +`tests/documentation_examples_e2e_tests.rs:3` carries `#![cfg(unix)]`, and +`.github/workflows/ci-windows.yml` does not set `NETSUKE_REQUIRE_NINJA`. + +Assertions use `googletest` matchers with `rstest`, and `pretty_assertions` for +equality diffs, per `AGENTS.md`. Prefer `.expect(...)` over `.unwrap()` in test +bodies; the repository's lint exemption covers `#[test]` and `#[rstest]` bodies +only, not helpers outside them. + +New identifiers added to `EXPECTED_EXAMPLE_IDS` in +`tests/documentation_examples_tests.rs`, in sorted position among the other +`readme-` entries: + +- `readme-backtick-rejection-manifest` +- `readme-quoted-path-manifest` +- `readme-safe-placeholder-manifest` + +Also add one bullet to `docs/repository-layout.md` near lines 60-65 recording +that a change to any README section must be mirrored in the six translations. +Nothing records this obligation today. + +New document `docs/adr-027-command-placeholder-contract.md`. Its required +sections, in the order `docs/documentation-style-guide.md:374-384` mandates, +are **Status**, **Date**, and **Context and Problem Statement**. Add the +conditional sections **Decision Drivers**, **Options Considered**, and +**Decision Outcome** (lines 386-398), because three contested questions with +two or more defensible answers each is exactly the complexity those sections +exist for. Status is `Accepted` with today's date once the plan is approved. The +`arch-decision-records` skill's Y-Statement phrasing is a useful way to +compress each decision's rationale into a sentence, but it is not required by +this repository's style guide; use it inside **Decision Outcome** only if it +reads naturally. + +## Progress + +### Resumption — 2026-09-23 + +The user authorized EP-M3 through EP-M5. The checkout is clean at `24dfc3fe`, +with no active rebase. PR 699 already has the requested title without `Plan:`; +`lody session rename --title` successfully reapplied that title. + +The implementation sweep re-read ADR-027, ADR-034, the prescribed skills, +project style, fixture guidance, and the documented-example harness. Leta +confirmed `find_script_substitution` delegates to the common recognizer and +`is_valid_command_for_shell` exempts PowerShell. The inherited introductory +asymmetry claims, obsolete test name, first-line requirement, and stale closing +approval sentence are reconciled with the revised decisions. The test module +starts with `//!` and Unix gating, then declares the required harness module. + +EP-M3 red stage: added seven named test groups and three registry identifiers; +`scrutineer` observed 25/25 missing-example failures before README prose was +written; the registry check separately named exactly the three missing IDs. The +matrix covers all table dollar forms plus `$PATH` in both recipe kinds. Test +helpers remain private to this integration target and compose the existing +manifest, graph, Ninja, workspace, and process APIs; no production abstraction +or dependency is introduced. + +### EP-M3 — implementation and controls complete (2026-09-23) + +Committed as `5e22c4e7`. The English README now has thirteen headings, the +seven planned parts (with a separate templated-value warning), and all three +marked examples. Seven named test groups expand to 25 cases, including eighteen +marker/variable matrix cells. The developers' guide records helper ownership. +No production code or dependency changed. English-only structural divergence is +the explicitly permitted EP-M3 to EP-M4 window. + +All deterministic gates passed before the commit, through `scrutineer`: +`make check-fmt`, `make typecheck`, `make lint`, `make doc-coverage` (98.81%), +`NETSUKE_REQUIRE_NINJA=1 make test` (3338 passed, 5 skipped; doctests passed), +`make markdownlint`, and `make nixie`. An initial Clippy shadowed-binding +finding was fixed without suppression before the passing run. Local nextest +0.9.133 matches the CI pin. Logs are recorded in `Artefacts and notes`. + +The three post-commit controls are complete and all edits were restored from +file copies, with clean `git status --porcelain` between mutations: + +- Control 1: appended a harmless shell `$in` reference to the accepted example + and asserted `$$in` in generated Ninja. The filtered build test passed. +- Control 2: restored exact bare `$in` recognition in scripts. Only the script + `$in` matrix cell failed (24 passed, 1 failed), detecting the ADR-034 + reversal. +- Control 3: applied the stored parity mutation. The odd-backtick case failed + because the malformed command was accepted; ordinary command cases also + failed because even parity was rejected (12 passed, 13 failed). + +The five executable obligations are discharged. The structural-parity +obligation remains for EP-M4. The committed EP-M3 review completed with zero +findings against `24dfc3fe`: +`coderabbit review --agent --committed --base-commit 24dfc3fe`, log +`/tmp/coderabbit-netsuke-readme-m3.out`. The restored focused suite passed +56/56 before that review; the evidence-only commit `31ed9e68` passed +formatting, Markdown, and Mermaid checks. EP-M4 may proceed. + +### EP-M4 — complete (2026-09-23) + +A `scribe` owns only the six translated READMEs, using the approved English +section through context pack `pk_imhdep7m`. The parent owns the manual parity +script and repository-layout obligation. The script compares heading counts and +ordered levels outside fenced examples and reports every mismatch; it does not +claim to verify translated meaning. No existing README parity helper was found +in `scripts/`. The script remains outside Makefile and CI gates, as +`D-NO-PARITY-GATE` requires. `scrutineer` validated `bash -n` and ShellCheck, +then exercised the script in an isolated fixture: identical editions pass, a +missing heading fails, a changed level fails, and a fenced fake heading is +ignored. Evidence: `/tmp/parity-checker-netsuke-m4.out` (exit statuses 0, 1, 1, +0 respectively). + +All six translations now carry the same contract. The parent checked the +localized terminology and qualified backtick use as command substitution, to +preserve the PowerShell distinction. All seven editions have 13 headings with +identical levels; three YAML fences and the table data match exactly, and no +translation has a `tested-example` marker. Evidence: +`/tmp/readme-parity-conformance-netsuke-m4-final.out`. Early conformance-script +failures came from incorrect expected heading counts and comparing translated +table headers; correcting the external checker required no product changes. + +The first formatting gate requested one additional Japanese paragraph wrap; +`make fmt` applied it and the subsequent check passed. Full gates passed: +`check-fmt`, `typecheck`, `lint`, `doc-coverage` (98.81%), +`NETSUKE_REQUIRE_NINJA=1 make test` (3338 passed, five skipped; doctests +passed), `markdownlint`, and `nixie`. Logs: +`/tmp/{check-fmt,typecheck,lint,doc-coverage,test,markdownlint,nixie}-netsuke-readme-m4-fix1.out`. +`OBL-STRUCTURAL-PARITY` is discharged. Commit and milestone review follow; +EP-M5 has not started. + +EP-M4 was committed as `cdb98ac8`. The first CodeRabbit attempt failed before +analysis with a WebSocket closure; the retry completed with two findings +(`/tmp/coderabbit-netsuke-readme-m4-2.out`). Both are addressed: the Portuguese +compatibility statement now uses an explicit conditional, and the parity +checker matches hashes with `+` and checks their length separately instead of +relying on interval expressions unsupported by older `awk` implementations. The +local interpreter is GNU Awk 5.3.0; no older `mawk` is installed, so the +portability repair removes that dependency without claiming a legacy-runtime +execution. The full gate stack then passed again, including 3338 tests and +98.81% documentation coverage; logs use +`/tmp/*-netsuke-readme-m4-review-fix.out`. The fixes were committed as +`2800c8af`. + +The next completed review identified a separate fence-tracking defect +(`/tmp/coderabbit-netsuke-readme-m4-fixed.out`). Scratch fixtures confirmed +that a shorter fence inside a four-backtick example, or a mismatched tilde +fence, exposed a fake heading and incorrectly failed parity (both exit 1; +`/tmp/fence-tracking-red-netsuke-m4.out`). The checker now records the opening +character and width, permits up to three leading spaces, and requires a +matching closing fence of sufficient width with no trailing non-whitespace. It +also rejects backticks in a backtick fence's info string. This repairs the +existing fenced-example exclusion; it does not add a CI gate or a general +Markdown validator. Full gates passed again, including 3338 tests and 98.81% +coverage, with logs `/tmp/*-netsuke-readme-m4-fences.out`; the repair was +committed as `a5cf2f03`. + +The next review completed with one further heading-recognition concern +(`/tmp/coderabbit-netsuke-readme-m4-fences.out`). Scratch cases confirmed that +one, two, or three leading spaces caused a valid heading to disappear from the +count (`/tmp/readme-heading-indent-netsuke-m4.out`, all incorrectly exited 1). +The matcher now uses the already-trimmed line and indentation bound. It also +accepts tab separators and empty hash headings while rejecting seven hashes and +hash-prefixed words. These are the same ATX heading syntax; no new validation +responsibility is introduced. Full gates passed, including 3338 tests and +98.81% coverage, with logs `/tmp/*-netsuke-readme-m4-headings.out`; the repair +was committed as `1799474f`. + +The subsequent completed review reported eight findings +(`/tmp/coderabbit-netsuke-readme-m4-headings.out`). Seven wording findings +(four originals and three duplicate restatements) request impersonal German, +French, Portuguese, and Chinese README prose. These are not applied: +`docs/documentation-style-guide.md`, *Punctuation and grammar*, explicitly +excepts the README from its pronoun restriction, and the translated README +family follows that same reader-facing voice. The locale glossary prescribes +formal German `Sie` for direct instructions and each edition already uses +localized direct address. Retaining those translations preserves both the +English meaning and the established locale convention; this does not extend the +exception to internal documentation or diagnostics. + +The remaining finding is valid: CRLF closing fences were not recognized. An +immutable-baseline scratch case converted only the German fixture to CRLF; the +checker incorrectly reported one heading instead of two and exited 1 +(`/tmp/readme-crlf-fence-netsuke-m4.out`). Each line now loses one trailing +carriage return before indentation, fence, or heading processing. Final gates +and review passed before EP-M5. Full logs use +`/tmp/*-netsuke-readme-m4-crlf.out` (3338 tests passed, 98.81% coverage), and +commit `bce3b2a0` carries the fix. The final repair review +`coderabbit review --agent --committed --base-commit 1799474f` completed with +zero findings (`/tmp/coderabbit-netsuke-readme-m4-crlf.out`). All EP-M4 +concerns are fixed or dispositioned above. + +### EP-M5 — complete (2026-09-23) + +Roadmap item 4.4.1 and its four children are checked, with an ADR-027 and +translation completion note. `mapsplice replace` was attempted in preview mode +first, but rejected unrelated pre-existing structure in step 3.14: "task list +for step `3.14` cannot appear after trailing step content". No roadmap write +occurred. After inspecting that failure, a bounded replacement of only the +4.4.1 block preserves all other roadmap content and dependencies. ADR-027 and +the formal-verification guide now describe the README section as present rather +than future work. + +Every discovery was reconciled with the implementation and current documents: +ADR-034's uniform marker set governs all editions and tests; the backtick +parity and command-only `shlex` limits are explicit; JSON diagnostic mode and +partial path quoting are reflected in the executable examples; the obsolete +module-file footnote is corrected; marker registration and mutation controls +are recorded; the ADR numbering and rebase observations are historical. The +4.2.3 status discrepancy and absent `SECURITY.md` remain explicitly outside +this task. The historical private Rustdoc link observation also remains unfixed +upstream: `src/ir/cmd_interpolate/mod.rs` still links to `interpolate_command`. +The earlier branch correction was reverted with the ADR-034 reconciliation, and +no production-module change is made here. This pre-existing issue is distinct +from the public Rustdoc and coverage gates, which pass; it is not claimed as an +EP-M2 deliverable. + +The final full gate sequence passed on `636bf523`: formatting, typechecking, +lint, documentation coverage (98.81%), all 3338 Nextest cases (five skipped), +workspace doctests, Markdown lint, and Mermaid validation. README parity and +example/table conformance also passed. Logs: +`/tmp/{parity,conformance,check-fmt,typecheck,lint,doc-coverage,test,markdownlint,nixie}-netsuke-readme-m5.out`. + +The whole-branch review against merge base `c31057c1` completed with seven +findings (`/tmp/coderabbit-netsuke-readme-final.out`). Four repeat the already +dispositioned README direct-address requests. Three are repaired: the plan's +Bash pipelines explicitly enable fail-fast and `pipefail` (the expected-red run +uses an `if` guard and requires inspection of the named failures); the ADR Date +field contains only its original ISO date, retaining the revision note; and the +formal guide, ADR, and plan distinguish active POSIX backticks from literal +backticks inside single quotes. The full gate run already used `pipefail`; the +correction makes the reproducible plan commands match it. The final gate recipe +now also lists the documentation-coverage gate actually run throughout this +task. The repairs were committed as `cf985eb1` after formatting, Markdown, and +Mermaid gates passed. All four revised Bash snippets parse, and isolated +child-shell controls verify failure propagation and the expected-red guard. +Evidence: `/tmp/*-netsuke-readme-final-doc-fixes.out`, including +`/tmp/bash-snippets-netsuke-readme-final-doc-fixes.out` and +`/tmp/pipeline-and-redguard-netsuke-readme-final-doc-fixes.out`. + +The repair review against `636bf523` completed with zero findings +(`/tmp/coderabbit-netsuke-readme-final-doc-fixes.out`). Its first attempt lost +its WebSocket connection and was retried without a rate limit; the failed +attempt is preserved at +`/tmp/coderabbit-netsuke-readme-final-doc-fixes-attempt1.out`. Every valid +whole-branch finding is fixed; the four repeated README-voice suggestions are +dispositioned against the applicable style guidance. No concerns remain open. + +### Follow-up — 2026-09-24 + +A review found that the developers' guide did not explicitly scope the +odd-backtick and `shlex` checks to POSIX and Bash `command:` recipes. The +wording now states that scope; PowerShell's separate marker-protection boundary +remains as described above. `check-fmt`, `markdownlint`, and `nixie` passed; +logs are `/tmp/{check-fmt,markdownlint,nixie}-netsuke-shell-scope-followup.out`. + +The final implementation comprises 17 files and fewer than 1400 net added +lines, excluding this living plan, within the agreed 18-file/1600-line limits. +Post-commit inspection found no further refactor needed: the security-test +helpers remain local to their test module and the parity script has one manual +reviewer responsibility. No production code, dependency, public interface, or +historical ADR other than the task's ADR-027 changed. + +### Review follow-up — 2026-09-24 + +The user reopened this completed work for the remaining PR review comments and +explicitly prohibited another CodeRabbit review. The inert-region finding is +valid: POSIX lexical scanning copies comment and heredoc-body text verbatim +after manifest rendering has introduced `INS_TOKEN` or `OUTS_TOKEN`, so those +tokens can remain in generated recipe text. Heredoc delimiters are scanned as +active text. PowerShell `command:` interpolation is separate; PowerShell +`script:` recipes use the same POSIX-aware script scanner. The README table and +all six translations now qualify `yes` as active-text expansion, warn against +markers in inert regions, and state this route distinction. ADR-027 and the +developers' guide now record the same boundary. + +The README security tests now check active expansion against comment and +heredoc-body token preservation across POSIX/Bash command and script cases. +Command heredocs are rejected by Ninja generation because recipe newlines are +unsafe Ninja control characters; the test pins that typed backend failure, and +a separate script heredoc is executed with Ninja to verify the literal body +text. A new `tests/readme_parity_tests.rs` fixture suite exercises the actual +manual parity checker for matching and mismatched heading structures, fence +handling, indentation, CRLF input, missing editions, and invocation outside the +fixture root. The initial focused run caught two cases that incorrectly +expected command heredocs to pass Ninja generation; tests now pin the actual +typed `NinjaGenError::UnsafeNinjaValue` rejection. The final focused suite +passes 82/82. + +The completed review follow-up adds the requested persistent regression +coverage: 34 README security cases and 17 parity-checker fixtures. Against +merge base `c31057c1`, the final implementation changes 18 paths and adds 1774 +net lines, excluding this plan. A test-only lint repair was made without a +suppression. All local gates now pass; the final evidence is listed below. The +remaining CodeRabbit thread confirmations, current-head CI, exact approval +comment, and merge are separate PR closeout evidence. No further CodeRabbit +review is authorized. + +### EP-M1 — complete (`3594b568`) + +The ADR, its index entry, and the design-document cross-reference are written, +formatted, gated, and pushed. `make check-fmt` reported +`143 files left unchanged`; `make markdownlint` reported `0 error(s)` over 143 +files. The `typos-config-builder gate` step inside `make markdownlint` printed +`refreshed: typos.toml` but left the working tree clean — +`git status --porcelain` showed only the three intended changes, so no +generated spelling configuration drifted into the commit. + +The branch was rebased onto `origin/main` after the plan's revision-2 commits +had been pushed, so the three plan commits on the remote are the pre-rebase +versions. `git patch-id --stable` confirms all three are content-identical to +their rebased counterparts (`d535a8e4`, `f37a00e8`, `f443c1bd` in both sets), +so the force-with-lease push that followed was a pure lineage change, not a +rewrite of reviewed content. + +Also updated out of band, per the task instruction: PR 699's title lost its +literal `Plan:` prefix and now reads *Document the command placeholder contract +in the README (4.4.1)*, and the Lody session was renamed to match with +`lody session rename --title`. + +### Stage A — complete + +Stage A (settle the contract) is **complete**. The branch was rebased onto +`origin/main` at `0ba6672f` before any implementation, and all three facts were +re-verified against the rebased tree. Citation drift against revision 2, for +the record: + +- `src/ir/cmd_interpolate/mod.rs`: `quote_double_quoted_path` :154, + `has_unmatched_backticks` :172-174 (still `rem_euclid(2) != 0`), + `interpolate_command_with_bindings` :189, `interpolate_script_with_bindings` + :207, `invalid_command_error` :215, `is_valid_command_for_shell` :226-231 + (PowerShell early-return at :227, `shlex::split` at :230), + `find_substitution` :261, `find_script_substitution` :269. + `try_match_dollar_placeholder` was cited here at :278 in revision 2; ADR-034 + deleted it. +- `src/ir/cmd_interpolate/substitution.rs`: `append_protected_character` :364. +- `src/ir/cmd_interpolate/script_substitution.rs`: + `append_substitution_or_character` :222, backtick/`$()` rejection at :232. +- `src/ninja_gen/mod.rs`: `assert_shell_command` :283-290, + `script_shell_text` :343-356. +- `locales/en-GB/messages.ftl`: `ir.invalid_command` at :188. +- `Cargo.toml`: `shlex = "2.0.1"` at :139; `shell-quote` at :137-138. +- `docs/roadmap.md`: item 4.4.1 at :589. +- `docs/formal-verification-methods-in-netsuke.md`: §Kani for command + interpolation at :62, §Command placeholder contract at :263, stale `[^8]` + path at :332. +- `docs/developers-guide.md`: §Command interpolation contract at :3362. +- `docs/users-guide.md`: §Review the safety boundary at :1901. +- `docs/netsuke-design.md`: the Fact A sentence at :290-291, §6.3 at :2800. +- `docs/contents.md`: `## Decision records` at :88, ADR-026 entry at :170. +- `tests/documentation_examples_tests.rs`: `EXPECTED_EXAMPLE_IDS` `readme-` + block at :54-58, registry test at :143. + +### EP-M2 — complete, then partly superseded on rebase + +**Read this section as two layers.** EP-M2 was completed on 2026-09-22 against +the pre-ADR-034 tree, and most of what it wrote was undone a day later when the +branch was rebased and reconciled. The original record is kept because the +milestone genuinely ran and its gate evidence is real; the superseding note +says what now stands. + +Originally: four documents corrected, one doc comment fixed, no code touched. +`docs/developers-guide.md` gained the per-recipe-kind placeholder set and an +ADR-027 link in §*Command interpolation contract*, plus a corrected +lowering-stages bullet. `docs/users-guide.md` split the "Write shell dollar +expressions normally" bullet into three and reworded the migration bullet. +`docs/formal-verification-methods-in-netsuke.md` corrected both the Kani +section and the contract section, the latter gaining a **Settled** paragraph +answering `FV-CPC-Q1` through `FV-CPC-Q3`, plus the `[^8]` path fix. The doc +comment on `src/ir/cmd_interpolate/mod.rs` named `$in`/`$out` as script-only. +`docs/netsuke-design.md` was deliberately not edited, as the canonical wording +the others converged on. + +**What stands after the 2026-09-23 rebase and ADR-034 reconciliation:** + +- `docs/users-guide.md` — **no branch change at all.** `main` rewrote this + section when ADR-034 landed, and its wording is better than the branch's: it + carries the three-term marker / internal-token / shell-variable glossary this + plan had asked for. The file is byte-identical to `origin/main`. +- `src/ir/cmd_interpolate/mod.rs` — **no branch change at all**, byte-identical + to `origin/main`. The doc comment EP-M2 wrote described the removed asymmetry. +- `docs/developers-guide.md` — the asymmetry wording is gone; what survives is + the still-correct clarification that the backtick-parity and `shlex` guards + are `command:`-only, which ADR-034 did not touch, plus an ADR-034 citation. +- `docs/formal-verification-methods-in-netsuke.md` — the **Settled** paragraph + and the `[^8]` path fix survive; the first `FV-CPC-Q1` bullet now states the + uniform set and credits ADR-034. +- `docs/netsuke-design.md` — now *is* edited, minimally, to state the uniform + rule and cite ADR-034. + +So EP-M2's requirement — that no document contradict another about the +placeholder contract — holds, but it is satisfied largely by `main`'s work +rather than the branch's. The milestone stays `[x]`: its obligation was +consistency, and the tree is consistent. + +Gate evidence for the original EP-M2 run, through `scrutineer`: +`make check-fmt`, `make markdownlint`, `make typecheck`, `make lint`, +`make test` (`3188 tests run: 3188 passed, 5 skipped`), and `make nixie` all +PASS. That evidence is **stale for acceptance** — it was bound to a head that +no longer exists. The rebased and reconciled head was re-gated on 2026-09-23: +all six gates PASS, `3313 tests run: 3313 passed, 5 skipped`, with +`NETSUKE_REQUIRE_NINJA=1`. + +One formatting lesson survives both passes: `mdtablefix --wrap` split a bold +span across a line break in `docs/developers-guide.md`, rendering +``**`script:`-only**`` as a stray `**` at end of line. Reworded to drop the +bold rather than fight the wrapper. It is the second time in this plan that the +rewrapper has damaged emphasis, which is why each file is read back after an +automated rewrap rather than trusting exit 0. + +The step-5 verification grep is recorded in `Concrete steps`; its expected +survivors changed with ADR-034, because a statement that a dollar form is a +shell variable in *both* recipe kinds is now correct and needs no edit. + +- [x] EP-M1 — ADR-027 written, indexed, and referenced from the design + document; historical milestone evidence is recorded above. Final review + covers the full branch. +- [x] EP-M2 — internal documentation reconciled with ADR-034. The surviving + branch edits and upstream-provided corrections are distinguished above; + the old pre-rebase gate evidence is superseded by the current full gates. + +- [x] EP-M3 — README section and three executable examples; red observed + before green; three negative controls recorded after the commit. +- [x] EP-M4 — six translated READMEs regain structural parity; + `docs/repository-layout.md` records the recurring obligation. +- [x] EP-M5 — roadmap 4.4.1 marked done; full gate sequence green; + whole-branch review findings fixed or dispositioned. + +## Surprises & discoveries + +- Observation (2026-09-23): the first green run passed 47/56 cases. Seven + script assertions omitted the outer wrapper's backslash before the doubled + dollar; one path assertion expected whole-word quoting instead of + `shell-quote`'s valid `input' file.txt'` spelling. Both were test-oracle + representation errors, not changes to the placeholder or quoting contract. + The default human CLI rendered only the enclosing graph error. JSON mode + preserves the underlying interpolation cause, so the executable README + instruction and CLI test now use `--json --locale en-GB`. The IR rejection + remains unchanged. Evidence: `/tmp/focused-netsuke-readme-m3.out` and the + corrected run `/tmp/focused-netsuke-readme-m3-fix1.out` (56/56 passed). + +- Observation: `main` reversed Fact A while this branch was mid-implementation. + `script:` recipes no longer lower bare `$in` and `$out` to paths; every + dollar-prefixed form is now a shell variable in both recipe kinds. Evidence: + commit `382395bc` ("Preserve script shell variables (#737) (#753)", merged + 2026-09-20) and + [ADR-034](../adr-034-preserve-script-in-out-as-shell-variables.md). It deleted + `try_match_dollar_placeholder` and reduced `find_script_substitution` to a + delegation to `find_substitution`. Verified on the rebased tree, not inferred + from the commit message. + + Impact: substantial, and mostly subtractive. Fact A inverts; `D1` becomes a + uniform rule; `D1-LEGACY` is closed as `D1-RESOLVED`; `OBL-RECIPE-KIND` + becomes `OBL-UNIFORM-SET`; ADR-027 is revised rather than shipped + born-superseded; and the branch's own EP-M2 corrections to + `docs/developers-guide.md` and + `docs/formal-verification-methods-in-netsuke.md` had to be partly reverted, + because they had just finished documenting the asymmetry accurately. + `docs/users-guide.md` and `src/ir/cmd_interpolate/mod.rs` needed no branch + edit at all: `main` rewrote both, and its version is better than the branch's + — it added the three-term marker / internal-token / shell-variable glossary + this plan had been asking for. + + Lesson, recorded because it generalizes: this plan's headline finding was a + *defect report about the documentation*, and the project fixed the defect by + changing the code instead. A plan whose value rests on an irregularity should + expect the irregularity to be removed, and should be written so that its + other obligations survive that. Here they did — `D2`, `D3`, `OBL-QUOTING`, + `OBL-BACKTICK-REJECT`, and `OBL-SHLEX-SCOPE` were untouched, and + `OBL-SHLEX-SCOPE` is now the only recipe-kind asymmetry left to document. + +- Observation (**superseded on 2026-09-23 by ADR-034; recorded as found**): + `$in` and `$out` **were** substituted in `script:` recipes, contradicting a + plain reading of both `docs/developers-guide.md:3186-3190` and + `docs/formal-verification-methods-in-netsuke.md:265-266`. Evidence: + `find_script_substitution` and `try_match_dollar_placeholder` + (`src/ir/cmd_interpolate/mod.rs:269-297`); `substitute_script` is reached from + `src/ir/from_manifest_support.rs:114`. Impact: EP-M2 exists because of this. + The README must state the placeholder set per recipe kind rather than as a + single list. + +- Observation: the backtick guard is a whole-string parity count, not a + quoting-aware model, so a backtick inside single quotes still counts toward + the total. Evidence: `has_unmatched_backticks` + (`src/ir/cmd_interpolate/mod.rs:172-174`) is a single expression: + + ```rust + s.chars().filter(|&c| c == '`').count().rem_euclid(2) != 0 + ``` + + Impact: `D2` must describe this as a narrow structural guard. Describing it + as "unmatched backticks are rejected" without qualification would imply a + parser that does not exist. + +- Observation: adding a fenced code block to `README.md` is a test change, not + a prose change, because of the `tested-example` marker enforcement. Evidence: + `tests/documentation_examples/mod.rs:120-175` and the exact-set registry + check at `tests/documentation_examples_tests.rs:145-158`. Impact: this is + what makes a genuine red-green cycle available for a documentation item, and + it is why `Verification plan` has real obligations rather than a "no + invariants introduced" disclaimer. + +- Observation: `docs/roadmap.md` marks 4.2.3 complete while + `docs/execplans/4-2-3-kani-harnesses-for-command-interpolation.md:8` still + reads `Status: IN PROGRESS`. Evidence: both files, and the merge commit + `6c646f1c` that landed the work. Impact: the prerequisite is met in substance + — both Kani proofs and eleven Proptest properties exist and run. Flagged, not + fixed; see `Risks`. + +- Observation: footnote `[^8]` of + `docs/formal-verification-methods-in-netsuke.md:332-333` cites + `src/ir/cmd_interpolate.rs`, which no longer exists. Evidence: the module is + now the directory `src/ir/cmd_interpolate/`. Impact: corrected in EP-M2. + +- Observation: four factual errors in revision 1 of this plan, all found at + design review. Evidence and correction: `assert_shell_command` is at + `src/ninja_gen/mod.rs:283-290`, not `src/ninja_gen_recipe_shell.rs:282-290`; + `shlex = "2.0.1"` (`Cargo.toml:138`) is a caret requirement, not a pin, and + `README.md:110` installs without `--locked`; the registry test is + `every_documented_fence_has_a_known_unique_identifier` + (`tests/documentation_examples_tests.rs:143`), not + `documented_example_registry_is_exhaustive`, which does not exist; and + `docs/netsuke-design.md:290-291` already states Fact A correctly, so EP-M2 + must not touch it. Impact: an implementer told by Stage A to "open each cited + line" would have found test code at the wrong `assert_shell_command` citation + and could have triggered the contract-disagreement tolerance spuriously. + +- Observation (**superseded on 2026-09-23; `main` rewrote this section when + ADR-034 landed, and its wording is now correct**): + `docs/users-guide.md:1697-1698` and `:1713-1715` also contradicted Fact A, + and revision 1 neither listed them for correction nor could detect them. + Evidence: line 1697 reads "`{{ ins }}` and `{{ outs }}` are the only Netsuke + markers for input and output paths"; revision 1's grep + (`'literal .\$in\|\$in. and .\$out. remain'`) matches neither, because the + claim wraps across a line break. Impact: the README's closing pointer sends + readers to precisely that page. Three reviewers independently flagged this as + blocking. EP-M2 now covers it and the grep in `Concrete steps` step 5 is + rewritten. + +- Observation: `UndefinedBehavior::Strict` (`src/manifest/mod.rs:128`) makes + revision 1's negative control non-discriminating. Evidence: `ins` and `outs` + are ordinary MiniJinja context entries (`src/manifest/render.rs:305-306`), so + `{{ inputs }}` fails during rendering, before `find_substitution` is reached. + Impact: the control proved only that MiniJinja is strict, and would have + passed against an implementation recognizing a completely different marker + set. Replaced with a control that discriminates. + +- Observation: continuous integration sets `NETSUKE_REQUIRE_NINJA=1` + (`.github/workflows/ci.yml:78`), which turns the `ninja` skip guard into a + panic (`test_support/src/ninja.rs:73-89`). Impact: `OBL-PLACEHOLDERS` *is* + discharged in CI, contrary to a worry raised at review — but a green local + `make test` without the variable is not evidence, and revision 1 stated + flatly that the case "skips when `ninja` is absent". The test commands now + set it. + +- Observation: the repository has no `SECURITY.md` in any conventional + location. Evidence: checked at the repository root, `.github/`, and `docs/`. + Impact: out of scope for 4.4.1, which asks specifically for a README section, + but for a tool whose function is executing shell strings this is a genuine + gap worth its own roadmap item. Recorded here so it is not lost. + +- Observation: the branch was 29 commits behind `origin/main` when + implementation began, and the plan's ADR ceiling had moved. Evidence: + `git rev-list --left-right --count origin/main...HEAD` reported `29 3`; the + highest existing ADR on main is 026 + (`docs/adr-026-manifest-environment-access-policy.md`), not 020. Impact: + `Concrete steps` step 3 correctly anticipated this and told the implementer + to use the next free number, so the plan's ADR became **027**. A rebase onto + `origin/main` at `0ba6672f` was performed before any file this plan touches + was written, which also moved most line citations; all are re-recorded in + `Progress`. PR 621, an unmerged and conflicted branch, still holds a file + named `docs/adr-021-manifest-linting-under-netsuke-check.md`; taking 021 + would collide on merge, which is a second reason to skip it. + +- Observation: `tests/execplan_status_contract_tests.rs`, cited in a prior + session's notes as enforcing the ExecPlan status vocabulary, does not exist at + `origin/main` `0ba6672f`. Evidence: + `ls tests/execplan_status_contract_tests.rs` fails. Impact: none for this + plan, whose header stays inside the closed set (`DRAFT`, now `IN PROGRESS`), + but a header value outside that set would not be caught by a test on this + revision. + +- Observation: the module doc comment on `src/ir/cmd_interpolate/mod.rs:3` links + to `[`interpolate_command`]`, and no such item exists — the public entry + points are `interpolate_command_with_bindings` and + `interpolate_script_with_bindings` (`:189`, `:207`). Evidence: + `RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo doc --workspace --no-deps` + passes, but adding `--document-private-items` fails with an unresolved-link + error naming that item at `mod.rs:36` of the log, one of nineteen such + errors. The `lint-clippy` target runs `cargo doc` *without* + `--document-private-items`, so `broken_intra_doc_links` — which + `[workspace.lints.rustdoc]` sets to `deny` (`Cargo.toml:267`) — never sees + it. Impact: EP-M2 corrects this doc comment anyway for its Fact A wording, so + the dead link is fixed as a side effect; no new work is created. Recorded + because a comment edit that only fixed the prose would leave a latent + `deny`-level violation behind, and because the nineteen errors show the + private-items path is not otherwise gate-covered on this revision. + +## Decision log + +- Decision `D-DIAGNOSTIC-MODE`: name the existing JSON diagnostic mode when + demonstrating the localized interpolation cause. Human output may report only + the graph wrapper. This is a presentation clarification of + `OBL-BACKTICK-REJECT`, not a changed acceptance contract or production fix. + Date: 2026-09-23. + +- Decision `D-RESUME-20260923`: continue the explicitly authorized remaining + milestones against ADR-034. The user supplied the existing oversized plan + diff when requesting continuation, so its known size is accepted; the revised + scope allowance excludes the living plan and includes the already-required + parity script. No new product scope is added. The module-doc requirement in + AGENTS.md takes precedence over the old instruction to put a module + declaration on the first line. All mutations remain temporary controls. + +- Historical decision `D1` (superseded by `D1-RESOLVED`): the supported + placeholder set is `{{ ins }}` and `{{ outs }}` in both recipe kinds, plus + `$in` and `$out` in `script:` recipes only. Rationale: this is what the code + does (Fact A). The alternative — documenting the simpler, uniform set the + existing prose implies — would be documenting a contract Netsuke does not + honour, which is worse than documenting an irregular one. Answers + `FV-CPC-Q1`. Date/Author: 2026-09-09, planning agent. Approved for + implementation; subsequently superseded by ADR-034. + +- Decision `D2`: the backtick handling is a deliberate, narrow structural + guard over Netsuke-owned lowering, not a temporary subset of a + command-substitution model. Rationale: calling it a temporary subset would + imply an intent to widen it, and nothing in the roadmap commits to that. + Calling it the full contract would imply it defends against author-written + command substitution, which it does not. The honest third option is to scope + the guarantee precisely to what it covers — markers — and to say explicitly + what it does not cover. Answers `FV-CPC-Q2`. Date/Author: 2026-09-09, + planning agent. Approved for implementation. + +- Decision `D3`: `shlex::split` is part of the observable acceptance contract + for `command:` recipes on POSIX and Bash routes, but the precise accepted set + is not a stability commitment. Rationale: users can and do rely on the + rejection, which has a stable, localized diagnostic, so denying that it is + part of the contract would be false. But the accepted set is `shlex` 2.0.1's + approximation of POSIX (`AX-SHLEX`), and pinning the project to it across + future crate versions would be a commitment nobody has agreed to. Splitting + the answer along the rejection/acceptance axis is more precise than either of + the two options `FV-CPC-Q3` offers. Answers `FV-CPC-Q3`. Date/Author: + 2026-09-09, planning agent. Approved for implementation. + +- Decision `D-SCOPE`: the plan corrects the two inaccurate upstream documents + and records `D1`–`D3` in a new ADR, rather than writing the README section + alone. Rationale: the user selected this scope when asked. It is also + required by `AGENTS.md`, which mandates an ADR for a substantive decision and + proactive correction of documentation that a change makes inaccurate. Leaving + `docs/developers-guide.md` contradicting the new README would create the + exact ambiguity 4.4.1 exists to remove. Date/Author: 2026-09-09, user and + planning agent. + +- Decision `D-TRANSLATE`: all six translated READMEs receive the section + within this plan, as milestone EP-M4. Rationale: the user selected this. The + seven READMEs currently have exact structural parity, and + `docs/repository-layout.md:60-65` treats the translations as first-class + editions rather than as a courtesy. A security-relevant section present only + in English would be a meaningful gap for a non-English reader. Date/Author: + 2026-09-09, user and planning agent. + +- Decision `D-NO-BDD`: the new coverage is `rstest` integration tests, not + `rstest-bdd` scenarios. Rationale: the observable behaviour is "this exact + published manifest is accepted / rejected with this diagnostic". A Gherkin + scenario would restate the test name in prose without adding a + stakeholder-legible workflow, and the repository already routes + documented-example coverage through plain integration tests + (`tests/documentation_examples_tests.rs`). Revisit if a reviewer identifies a + workflow, rather than a fact, worth specifying. Date/Author: 2026-09-09, + planning agent. + +- Decision `D-NO-PROPTEST`: no new property test or Kani harness is added. + Rationale: roadmap 4.2.3 already covers the invariant space with two Kani + proofs and eleven Proptest properties + (`src/ir/cmd_interpolate_property_tests.rs`). This item introduces no new + invariant over generated inputs; it introduces a documentation–implementation + agreement obligation, which exact examples plus negative controls discharge + better than generated ones. Adding a redundant property test would be + verification theatre. Recorded here because omitting property testing needs a + reason, not silence. Date/Author: 2026-09-09, planning agent. + +- Decision `D-NO-PORT`: no port or adapter is introduced. + Rationale: `hexagonal-architecture` was consulted. `src/ir/cmd_interpolate/` + is already domain policy returning a typed error, with the shell and Ninja + adapters downstream; a review sweep confirmed it imports only `localization`, + `camino`, `shell_quote`, `std::cell`, `std::collections`, + `super::IrGenError`, and `crate::recipe_shell`, and performs no input or + output. The boundary is correct; the plan documents it. + + One caveat the ADR must not gloss: `invalid_command_error` + (`src/ir/cmd_interpolate/mod.rs:214-222`) calls `localization::message(...)` + and stores a pre-rendered, locale-resolved human string inside the domain + error. That is presentation resolved within domain policy against ambient + state. It does not justify a port here, but ADR-027 must not cite this module + as an exemplar of a clean domain boundary. Date/Author: 2026-09-09, planning + agent; caveat added at design review. + +- Decision `D-NO-PARITY-GATE`: structural parity of the README family is + checked by `scripts/check-readme-parity.sh`, committed but wired into **no** + gate. Rationale: revision 1 argued a committed test "would be a new + repository-wide policy gate", which the review rightly called weak — the + policy already exists at `docs/repository-layout.md:60-65`, and a test would + only mechanize it. The real argument is the recurring tax: gating parity + makes every future README edit a seven-file edit to stay green, which is a + separate decision with a separate owner. A committed script that no gate runs + gives any reviewer a reproducible check for roughly fifteen lines and changes + no policy. Escalate if a reviewer wants it gated. Date/Author: 2026-09-09, + planning agent; revised at design review. + +- Decision `D-TABLE`: `D1` is presented as a table whose cells stay in English + while its column headers and surrounding prose are translated. Rationale: the + placeholder contract is two-dimensional (placeholder × recipe kind), so a + table is shorter and clearer than the two prose blocks revision 1 planned. + Cells that are code identifiers and yes/no make a mistranslation structurally + impossible and let a reviewer with no target-language reading diff all seven + editions. The countervailing risk — that a table reads as reassurance — is + handled by placing the non-guarantees above it. Date/Author: 2026-09-09, + design review. + +- Decision `D-M2-FIRST`: EP-M2 (document corrections) precedes EP-M3 (the + README). Rationale: revision 1 had the README land first, creating a plateau + at which `README.md` and `docs/users-guide.md` state opposite things about + `$in` in a `script:` — the exact ambiguity `D-SCOPE` argues 4.4.1 exists to + remove. The milestones are independent, so the reorder costs nothing. + Date/Author: 2026-09-09, design review. + +- Decision `D-CONTROLS-AFTER-COMMIT`: the negative controls run after EP-M3 is + committed, and restore by file copy rather than `git checkout`. Rationale: + revision 1 ran them before the commit and restored with + `git checkout -- README.md`, which would have deleted the entire + newly-written section because HEAD has no such section. The tests would have + failed loudly afterwards, so it was detectable — but the most expensive + artefact in the plan would already have been lost. Date/Author: 2026-09-09, + design review. + +- Decision `D1-RESOLVED` (supersedes `D1-LEGACY`): the question `D1-LEGACY` + raised — whether the `script:`-only `$in` / `$out` forms were intended or + vestigial — was settled upstream, against the reading this plan had hedged + towards. `D1-LEGACY` proposed documenting them as retained legacy with a + shadowing warning and a migration steer, and flagged that it needed the + owner's confirmation. ADR-034 answered it on `main` on 2026-09-20 by removing + the lowering outright, so the forms are now ordinary shell variables in both + recipe kinds and there is nothing left to hedge. + + Rationale for closing rather than revising: the decision was never this + plan's to make. `D1-LEGACY` said so explicitly — "that would be a code change + outside this item" — and the code change duly happened elsewhere. The evidence + `D1-LEGACY` weighed as *vestigial* (the users' guide migration note, the + test named `legacy_marker_aliases`, the Kani doc comment asserting "Literal + `$in` and `$out` must remain shell text") turned out to be the stronger + signal. Recording that is worth more than the decision itself: when a plan + finds evidence pointing both ways on someone else's decision, hedging in the + documentation and escalating was the right move, and it cost nothing when the + answer arrived. + + Consequence for this plan: `D1` states a uniform rule, `OBL-RECIPE-KIND` is + replaced by `OBL-UNIFORM-SET`, and ADR-027 is revised to agree with ADR-034 + rather than shipping born-superseded. Date/Author: 2026-09-23, on rebase. + +- Decision `D-REBASE-FIRST`: the branch was rebased onto `origin/main` at + `0ba6672f` before implementation began, and the plan's ADR was renumbered + from 021 to 027. Rationale: `origin/main` had moved 29 commits, including + 1696 changed lines in `docs/developers-guide.md` and 748 in + `docs/roadmap.md` — the two files EP-M2 and EP-M5 edit — so landing first and + rebasing later would have produced a conflict-laden, hard-to-review diff and + a stale set of citations in the ADR. Both `README.md` and + `src/ir/cmd_interpolate/` are untouched on main, so every fact this plan + rests on survived the rebase intact; only line numbers moved. + `Concrete steps` step 3 anticipated the ADR collision and prescribed the next + free number, so 027 follows the plan rather than deviating from it. + Date/Author: 2026-09-19, implementing agent. + +- Decision `D-4-2-3-STATUS`: the 4.2.3 execplan's stale `IN PROGRESS` header is + flagged and left unedited. Rationale: a merged execplan is a historical + document, and the substantive prerequisite is verifiably met. Editing another + item's completion record from this branch would obscure history. Date/Author: + 2026-09-09, planning agent. + +- Decision `D-CODERABBIT-EP-M1`: the milestone CodeRabbit pass returned twelve + findings over the whole branch delta, not just the two EP-M1 commits. Six + were accepted and fixed; six were rejected with reasons. No finding required + a behaviour change. The accepted set: + + - The stale `locales/en-GB/messages.ftl:179` citations in `Progress` and in + the `OBL-BACKTICK-REJECT` evidence became `:188`, the current line. + - The ADR ceiling in `Concrete steps` step 3 now records that it was `20` at + revision 2 and is `26` on the rebased tree, that step 4 is already done, and + that re-running the step must not mint a second record. + - `docs/netsuke-design.md` now qualifies the `shlex` and backtick rejection + to `command:` recipes on the POSIX and Bash routes, excluding `script:` and + PowerShell. The paragraph as written stated a guard that + `is_valid_command_for_shell` (`src/ir/cmd_interpolate/mod.rs:226-231`) + applies to one recipe kind and one pair of routes as though it applied to + both. + - The ADR no longer describes the README section as existing. `Status`, + `Rationale`, `Consequences`, and `Implementation references` now say the + section is established by this change set, not already published, because + at EP-M1 the README contains no such section. + - The artefact checklist in `Artefacts and notes` cited off-by-one step + numbers; it now names steps 6, 7, 8, 9, and 10. + - The acceptance inventory in `Validation and acceptance` named five test + cases and three obligations. It now names all seven cases and all five + obligations, plus the three negative controls the step-8 block defines. + + The rejected set, with reasons: + + - *"Change the ADR date to 18 September 2026."* Rejected as factually wrong. + Both `date -u` and GitHub's HTTP `Date` header report 2026-09-19, and + `3594b568` is dated 2026-09-19T01:52:11+02:00. The ADR date is correct. + - *"Set `NETSUKE_REQUIRE_NINJA=1` on the step-10 `make test`."* Accepted, and + the related "skips when `ninja` is absent" claim in the performance + criterion was corrected to match. + - *"`today's rejects` → `rejected today`."* Accepted as a readability fix. + - *"`both documents` → `all three documents`."* Accepted; three documents are + named in the surrounding text. + - *"Remove the second-person pronoun at line 55."* Accepted. The style guide + bars first and second person outside `README.md` + (`docs/documentation-style-guide.md:39`). The same fix was applied to the + Stage C framing bullet, which had the same defect, and to the two quoted + README sentences that prescribe the second person — those quotes describe + README prose, where the style guide permits it, so only the framing around + them changed. + - *"Reconcile findings 6 and 12."* Both target the same step-3 location with + contradictory numbers (expect 26, versus ceiling 27). Resolved in favour of + recording the historical `20`, the observed `26`, and the resulting `027`. + + Date/Author: 2026-09-19, implementing agent. + +- Decision `D-CODERABBIT-EP-M2`: the milestone CodeRabbit pass over `efb5ea17` + returned five findings, all severity *minor*, two of them duplicates of each + other. Two were accepted as real; three were rejected as already + dispositioned or as self-defeating. + + The accepted set: + + - The `**Settled.**` paragraph in + `docs/formal-verification-methods-in-netsuke.md:275` claimed in the present + tense that the contract "is documented for users in the README under + *Security and command interpolation*". That section does not exist yet: + EP-M3 creates it. This was a genuine factual error **introduced by + `efb5ea17`**, which is the sharper form of the irony — the commit exists to + remove a two-documents-disagree window and it opened a + document-claims-something-untrue window instead. Fixed by moving the claim + to future tense: "Roadmap item 4.4.1 will document this contract for users + in the README under *Security and command interpolation*, and it is decided + in ADR-027." The same review finding was raised against ADR-027's `Status` + line, where `5f13b35c` had already reworded it once; that wording was still + ambiguous, so it now reads "will carry them to users … that section is + planned work, not yet published, and this record is authoritative until it + lands." + - `docs/formal-verification-methods-in-netsuke.md` §*Kani for command + interpolation* and §*Command placeholder contract*, and + `docs/developers-guide.md`'s scanner paragraph, all read as though the + unmatched-backtick parity check and the `shlex` guard applied to both recipe + kinds. They apply to `command:` only. Verified against the code before + accepting: `is_valid_command_for_shell` + (`src/ir/cmd_interpolate/mod.rs:227-231`) is called only from + `interpolate_command_with_bindings` (`:195`), while + `interpolate_script_with_bindings` (`:208`) routes to `substitute_script`, + which never calls it. The marker-in-backticks invariant *is* shared — + `append_substitution_or_character` + (`src/ir/cmd_interpolate/script_substitution.rs:222-243`) rejects it too — + so the fix separates the invariant from the two follow-on checks rather + than demoting both. Note this is a **pre-existing** over-broad sentence in + the formal-verification document, carried unchanged into the rewritten + paragraph; only the developers-guide paragraph is a new claim. + + The rejected set, with reasons: + + - *"Change the plan's README section label at + `docs/execplans/4-4-1-….md:966` from `What Netsuke does not protect you + from` to ... `manifest authors`, while leaving the README wording + unchanged."* Rejected. Line 966 prescribes a literal bold label **for + `README.md`**, where `docs/documentation-style-guide.md:39` permits second + person; the finding's own carve-out concedes this. Following it would make + the plan prescribe a label that deliberately does not match the artefact + it describes. This is the same class of finding `D-CODERABBIT-EP-M1` + already reasoned through; the plan's framing around the quote was fixed + then, and the quote itself stays. + - *Two findings requesting the ADR date change to 18 September 2026.* Already + rejected at EP-M1 as factually wrong, and re-verified now: `date -u` reports + 2026-09-19. Not re-litigated. + + The re-review of `24197456`, run to confirm the two fixes landed, returned + one finding. It is **new** — not a re-raise — and it is correct, so it is + recorded as `D-CODERABBIT-EP-M2-RECHECK`: + + - Finding: `docs/adr-027-command-placeholder-contract.md` §*Route and + recipe-kind scope* said "PowerShell is outside both mechanisms", which + conflates the two halves of the promised invariant. The invariant is + "never substituted inside a backtick region **or** a `$( … )` command + substitution". PowerShell is outside the backtick half, because a backtick + is its escape character, but it is **inside** the `$( … )` half: + `power_shell_marker_protection` + (`src/ir/cmd_interpolate/substitution.rs:174-179`) treats any active + command substitution as protected and rejects the marker, pinned by + `power_shell_rejects_markers_without_a_context_safe_encoder`, case + `command_substitution` (`src/ir/cmd_interpolate_power_shell_tests.rs:9-22`). + Verified against the code before accepting. It is a genuine + document-disagrees-with-document defect too: + `docs/users-guide.md:1963-1967` already states the correct position. + + The same conflation appears twice in this plan's own `D2` narrative — + `Fact B` bullet 3 and the `D2` design statement's *Route scope* bullet — + so all three were fixed together. The `D2` *Route scope* bullet additionally + now carries an explicit instruction to EP-M3: the README's `D2` prose must + not say PowerShell is exempt from the marker invariant *tout court*. + + Provenance: the ADR paragraph dates from `3594b568` and was not touched by + `efb5ea17` or `24197456`, so this is pre-existing, first surfaced by the + re-review. That it took a second pass to surface is itself a lesson: the EP-M1 + pass reviewed the ADR and did not catch it, because the pass was read against + the *recipe-kind* axis the plan had been talking about, and this finding is + on the *route* axis. + + Date/Author: 2026-09-19, implementing agent. + +## Outcomes & retrospective + +All five milestones and six verification obligations are complete. The README +contract is implemented in all seven editions, with three executable examples, +34 security cases, three discriminating controls, and a manual parity checker +covered by 17 persistent fixtures. No production behaviour or dependency +changed. Roadmap 4.4.1 and its four criteria are checked. The final +implementation changes 18 paths and adds 1774 net lines against merge base +`c31057c1`, excluding this plan, within the authorized scope. + +The review loop strengthened the manual checker against older `awk`, nested +fences, indented headings, and CRLF files. The useful lesson is to validate the +parser assumptions of even a small documentation aid with discriminating +fixtures. Direct-address translation suggestions were checked against the +actual README style exception rather than applied mechanically. + +The requested inert-region finding was verified against the scanner and +corrected in the English and translated READMEs, ADR-027, and the developers' +guide. The checker-test finding is addressed with fixtures that execute the +actual script. Historical CodeRabbit and Codex review outcomes above remain +historical; thread confirmations for the current follow-up, current-head CI, +the requested approval comment, and merge remain separate PR closeout steps. + +The final deterministic checks passed: `make check-fmt`, `make typecheck`, +`make lint`, `make doc-coverage` (98.81%), `NETSUKE_REQUIRE_NINJA=1 make test` +(3364 passed, 5 skipped; 39 doctests passed, 6 ignored), `make markdownlint` (0 +errors), and `make nixie`. The focused suite passed 82/82, and the parity +checker reported the same 13-heading structure for all seven READMEs. +`make typecheck` passed before the final test-only lint cleanup; the final lint +and test runs passed after that cleanup. Logs: +`/tmp/focused-netsuke-review699-inert-repair.out`, +`/tmp/typecheck-netsuke-review699-inert-repair.out`, and +`/tmp/{check-fmt,lint,doc-coverage,test,markdownlint,nixie,readme-parity}-netsuke-review699-inert-repair2.out`. + +## Artefacts and notes + +EP-M3 red evidence (2026-09-23): + +- `/tmp/red-netsuke-readme-contract.out`: registry drift names exactly the + three new identifiers; 19 passed, 1 failed, then fail-fast stopped the run. + The initial pipeline omitted `pipefail`; its exit status is not evidence. +- `/tmp/red-readme-security-tests.out`: the follow-up used `pipefail` and + `--no-fail-fast`; exit 100, 25 tests failed with the expected missing-example + errors, covering all seven test groups and all three identifiers. + +EP-M3 acceptance evidence: + +- `/tmp/focused-netsuke-readme-m3-fix1.out`: 56/56 focused tests passed. +- `/tmp/typecheck-netsuke-readme-m3.out`: typecheck passed. +- `/tmp/check-fmt-netsuke-readme-m3-fix1.out`, + `/tmp/lint-netsuke-readme-m3-fix1.out`, + `/tmp/doc-coverage-netsuke-readme-m3-fix1.out`, + `/tmp/test-netsuke-readme-m3-fix1.out`, + `/tmp/markdownlint-netsuke-readme-m3-fix1.out`, and + `/tmp/nixie-netsuke-readme-m3-fix1.out`: all passed on the committed code. +- `/tmp/control1-netsuke-readme.out`: preservation witness passed. +- `/tmp/control2-netsuke-readme.out`: only the seeded script `$in` fault failed. +- `/tmp/control3-netsuke-readme.out`: odd-backtick acceptance detected. + +At minimum, retain: + +- the red transcript from `Concrete steps` step 6, showing both + missing-identifier failures and the registry-drift failure; +- the green transcript from step 7; +- the three negative-control transcripts from step 8; +- the parity output from step 9; +- the gate summary from step 10, naming any gate that did not run. + +Reference material gathered during planning, for the writer's use: + +- Ninja's manual, *Interpretation of the `command` variable*: on Unix the + `command` string is passed to `sh -c`; on Windows it is passed to + `CreateProcess`. This is the source of `AX-NINJA-SH` and the reason the + README must attribute shell interpretation to Ninja rather than to Netsuke. +- `shlex`'s crate documentation, *Compatibility*: the crate targets any + POSIX-compatible shell and also aims to match Python's `shlex` and C's + `wordexp`. Its `Shlex` iterator splits words; it performs no expansion, so a + backtick is an ordinary character to it. This is why `shlex::split` + succeeding says nothing about whether a shell would run a command + substitution in the same text — a point the README should make, because it is + the most likely misreading of `D3`. +- `RUSTSEC-2024-0006` and the `shlex::quoting_warning` module: the crate's + `quote` family cannot portably escape control characters, and versions before + 1.3.0 failed to quote `{` and `\xa0`. Netsuke quotes with `shell-quote`, not + `shlex`, so the advisory does not apply to Netsuke's quoting path — but it is + worth citing in ADR-027 as evidence for why `D3` declines to make the + accepted set a stability commitment. + +## Revision note + +Revision 3 (2026-09-23). Rebased onto `origin/main` at `c31057c1` and +reconciled with +[ADR-034](../adr-034-preserve-script-in-out-as-shell-variables.md), which +landed upstream on 2026-09-20 and reversed this plan's Fact A: `script:` +recipes no longer lower bare `$in` and `$out`, so the placeholder set is now +uniform across recipe kinds. + +What changed: Fact A inverted and annotated with its own reversal; `D1` +rewritten as a uniform rule; `D1-LEGACY` closed as `D1-RESOLVED`; +`OBL-RECIPE-KIND` replaced by `OBL-UNIFORM-SET`, which pins the uniformity the +README now claims and whose seeded fault is the pre-ADR-034 behaviour; +`OBL-PLACEHOLDERS`' discriminating control and `Concrete steps` 2, 5, and 8 +updated; the README table specification in Stage C simplified to one inert +dollar-form row with an instruction to state the uniformity in prose as well, +since an all-"no" row invites a reader to assume a column was dropped. ADR-027 +was revised rather than left to ship born-superseded: it now cites ADR-034 for +the set, retains the superseded reasoning under *Options considered* with a +fourth option recording what ADR-034 actually did, and keeps its backtick and +`shlex` decisions, which were re-verified unchanged against the rebased tree. + +What did not change: `D2`, `D3`, `OBL-QUOTING`, `OBL-BACKTICK-REJECT`, and +`OBL-SHLEX-SCOPE`. `has_unmatched_backticks` is still a whole-string parity +check, `is_valid_command_for_shell` still exempts PowerShell and still gates +`command:` recipes only, and `interpolate_script_with_bindings` still applies +no guard. `OBL-SHLEX-SCOPE` is therefore now the only recipe-kind asymmetry the +README must describe. + +Rebase record: `OLD_BASE` `0ba6672f`, `OLD_HEAD` `cb177308`, `TARGET` +`c31057c1`, ten commits replayed, two conflicts. `docs/contents.md` was +resolved by keeping both sides with ADR-027 in numeric position. +`docs/users-guide.md` and `src/ir/cmd_interpolate/mod.rs` were resolved to +`main`'s version in full, because every branch change to them documented the +removed asymmetry; both are now byte-identical to `origin/main`. + +Revision 3 (2026-09-19). Implementation begin. The plan was approved and work +started under `Status: IN PROGRESS`. Before any implementation the branch was +rebased onto `origin/main` at `0ba6672f`; because that moved the ADR ceiling +from 020 to 026, `docs/adr-021-command-placeholder-contract.md` became +`docs/adr-027-command-placeholder-contract.md` and every reference in this plan +was updated. Stage A re-verified Facts A, B, and C against the rebased tree and +confirmed all three; the citations revision 2 carried were refreshed for the +new line numbers and are recorded in `Progress`. No decision, obligation, +milestone, or tolerance changed. `Surprises & discoveries` gained the rebase +and ADR-collision observations, and `Decision log` gained `D-REBASE-FIRST`. + +Revision 2 (2026-09-09). Incorporates a six-lens `logisphere-design-review` +pass. Blocking changes: EP-M2 (document corrections) now precedes the README +milestone and covers `docs/users-guide.md:1697,1713`, removing a plateau at +which two normative documents would contradict each other; the negative +controls now run after the EP-M3 commit and restore by copy, because revision +1's `git checkout -- README.md` would have deleted the section it had just +written; `OBL-PLACEHOLDERS`' control was replaced because +`UndefinedBehavior::Strict` made the original non-discriminating; two +obligations were added, `OBL-RECIPE-KIND` for the plan's own headline finding +and `OBL-QUOTING` for the section's only positive security promise, neither of +which had any test in revision 1; and four factual errors were corrected (see +`Surprises & discoveries`). + +Substantive rewording: `D2` now promises the marker invariant and the parity +check at different strengths, so a future quoting-aware fix is not a breaking +change; `D3` names both `shlex` drift directions and states that passing the +gate does not make a command safe; `D1` frames the `script:`-only `$in`/`$out` +forms as retained legacy with a shadowing warning, and raises `D1-LEGACY` for +the owner. New decisions: `D-TABLE`, `D-M2-FIRST`, `D-CONTROLS-AFTER-COMMIT`, +`D1-LEGACY`; `D-NO-PARITY-GATE` keeps its outcome but replaces its rationale +and now commits an ungated script. The README section order was inverted to put +the live hazards before the placeholder table, and the templated-value +injection vector was promoted out of a bullet list into its own labelled part. +Scope tolerance was raised from 14 files / 700 lines to 18 / 1200, because the +review showed the original would fire on the plan's own expected path. + +Revision 1 (2026-09-09). Initial draft. Established the three contract +decisions from a direct reading of `src/ir/cmd_interpolate/` and scoped the +work to five milestones. + +Historical remaining work at resumption: EP-M3 through EP-M5. The user +explicitly authorized continued implementation on 2026-09-23. `D1-LEGACY` is +closed by ADR-034 and `D1-RESOLVED`. diff --git a/docs/formal-verification-methods-in-netsuke.md b/docs/formal-verification-methods-in-netsuke.md index bf83dc21e..c4a4daf7f 100644 --- a/docs/formal-verification-methods-in-netsuke.md +++ b/docs/formal-verification-methods-in-netsuke.md @@ -63,11 +63,13 @@ stronger proof obligations become worthwhile.[^7] `src/ir/cmd_interpolate/mod.rs` is another high-value target because it is compact, load-bearing, and security-sensitive. It recognizes only the internal -`INS_TOKEN` and `OUTS_TOKEN` markers emitted by manifest rendering; literal -`$in` and `$out` remain shell variables. POSIX-compatible routes reject markers -inside backticks and reject commands when backticks are unmatched or the -interpolated result fails the current `shlex` guard. PowerShell treats -backticks as native escapes rather than protected regions.[^8] +`INS_TOKEN` and `OUTS_TOKEN` tokens emitted by manifest rendering, identically +in both recipe kinds; `$in`, `$out`, `$ins`, and `$outs` are all literal shell +variables. On POSIX-compatible routes both recipe kinds reject markers inside +backticks; `command:` text additionally rejects unmatched backticks and a +substituted result that fails the current `shlex` guard, while scripts are +exempt from those two checks. PowerShell treats backticks as native escapes +rather than protected regions.[^8] Kani proves two allocation-free kernels. An eight-character symbolic window with a symbolic offset proves that literal `$in` and `$out` prefixes never @@ -262,20 +264,38 @@ Three contracts should be documented before proofs become gating checks. ### Command placeholder contract -The interpolation layer currently recognizes only the internal `INS_TOKEN` and -`OUTS_TOKEN` markers emitted by manifest rendering. Literal `$in` and `$out` -remain shell variables. On POSIX-compatible routes, markers inside backticks -are rejected, as are commands with unmatched backticks or a substituted result -that fails the current `shlex` guard.[^8] PowerShell treats a backtick as an -escape. This contract should be documented in the README under a new "Security -and command interpolation" section, as it is a user-facing guarantee that -affects manifest authoring. The project documentation should state whether: - -- those are the only supported placeholders, -- POSIX backtick rejection and PowerShell escape handling are the full contract - or a temporary subset of shell command-substitution handling, and -- `shlex::split` is part of the semantic acceptance contract or only a guard - against obviously malformed commands. +The interpolation layer recognizes only the internal `INS_TOKEN` and +`OUTS_TOKEN` tokens emitted by manifest rendering, identically in both recipe +kinds. `$in`, `$out`, `$ins`, and `$outs` are all literal shell variables, +following [ADR-034](adr-034-preserve-script-in-out-as-shell-variables.md). On +POSIX-compatible routes, markers inside backticks are rejected in both recipe +kinds. The two further checks — unmatched backticks and a substituted result +that fails the current `shlex` guard — run only on `command:` text; scripts may +legitimately contain heredocs and other syntax `shlex` cannot model.[^8] +PowerShell treats a backtick as an escape. + +**Settled.** Roadmap item 4.4.1 documents this contract for users in the +[README](../README.md#security-and-command-interpolation) and all six +translated editions under *Security and command interpolation*. The decisions +are recorded in [ADR-027](adr-027-command-placeholder-contract.md). The three +questions this section raised are now answered: + +- The supported placeholder set is `{{ ins }}` and `{{ outs }}`, and nothing + else, identically in both recipe kinds. Every dollar-prefixed form is a shell + variable. [ADR-034](adr-034-preserve-script-in-out-as-shell-variables.md) + settled this by removing the former `script:`-only lowering of `$in` and + `$out`; ADR-027 states the resulting contract. +- Backtick handling is two mechanisms at two strengths. Rejecting a marker + inside a backtick or `$( … )` region is a promised invariant. Rejecting a + `command:` whose substituted text contains an odd backtick count is a + conservative whole-string parity check that is *not* promised and may widen + without a breaking change. Netsuke leaves author-written backticks untouched. + On POSIX routes, active backticks can trigger shell command substitution; + backticks inside single quotes remain literal. +- `shlex::split` is part of the acceptance contract — its rejection is stable + and localized — but **not** a stability commitment about the precise accepted + set, because `Cargo.toml` carries a caret requirement rather than a version + pin. ### Cycle-participation contract @@ -329,8 +349,8 @@ into a proof-first shape that its current architecture does not need. default targets to keep emitted Ninja text deterministic. [^7]: [`src/ir/cycle.rs`](../src/ir/cycle.rs) defines cycle analysis and `canonicalize_cycle`. -[^8]: [`src/ir/cmd_interpolate.rs`](../src/ir/cmd_interpolate.rs) defines - placeholder substitution and command validation. +[^8]: [`src/ir/cmd_interpolate/mod.rs`](../src/ir/cmd_interpolate/mod.rs) + defines placeholder substitution and command validation. [^9]: [`src/manifest/mod.rs`](../src/manifest/mod.rs) describes the YAML-first manifest pipeline and re-exports expansion and rendering helpers. [^10]: [`src/runner/mod.rs`](../src/runner/mod.rs) generates the Ninja manifest diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index a0999f37d..d69df922a 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2825,10 +2825,20 @@ traversal needed to locate internal tokens without treating shell variables as markers; command recipes continue to use `substitution`, and both consume the shared bindings without composing their traversal states. Longer identifiers such as `$input` and `$output`, and non-placeholder text inside backticks, -remain unchanged. Unbalanced backticks or command text that `shlex` cannot -parse produce an IR error before an action is hashed. Ninja generation then -receives fully expanded command text and is responsible only for preserving the -scalar form or constructing the list-entry shell boundaries. +remain unchanged. In `command:` recipes on the POSIX and Bash routes, +unbalanced backticks or command text that `shlex` cannot parse produce an IR +error before an action is hashed; `script:` recipes and the PowerShell route +are outside that check. Ninja generation then receives fully expanded command +text and is responsible only for preserving the scalar form or constructing the +list-entry shell boundaries. + +[ADR-027](adr-027-command-placeholder-contract.md) settles which parts of this +behaviour are promises: the marker invariant is contractual, the odd-backtick +parity check is a conservative check that may widen without that being a +breaking change, and the `shlex` accepted set is not a stability commitment. +`{{ ins }}` and `{{ outs }}` are the only markers, identically in both recipe +kinds, following +[ADR-034](adr-034-preserve-script-in-out-as-shell-variables.md). ### 6.4 Automatic Security as a "Friendliness" Feature diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 0e0a6ddcd..620c54f14 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -63,6 +63,11 @@ output and some leaf files so the long-lived structure remains visible. overview, linked from the localization menu at the top of each README. They follow [the localization glossary](localization-glossary.md) and are exempt from the en-GB-oxendict spelling gate via `typos.local.toml`. +- README maintenance: mirror section changes in all six translated editions, + preserving heading order and levels, examples, and safety boundaries. Run + `bash scripts/check-readme-parity.sh` to compare heading counts and level + sequences; review translated meaning and example content separately. The + script is a manual reviewer aid and is not wired into continuous integration. - `.cargo/`: Cargo configuration that Cargo auto-discovers. It holds the repository's build standard: the `rustflags` every build takes — the parallel `rustc` frontend, plus the `mold` linker under a Linux-only `cfg` table. It diff --git a/docs/roadmap.md b/docs/roadmap.md index fc79dd09b..e4a3ea5fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -587,14 +587,17 @@ and test workflow intact. See ### 4.4. Contract documentation and optional proof kernels -- [ ] 4.4.1. Document the command placeholder contract in the README. Requires +- [x] 4.4.1. Document the command placeholder contract in the README. Requires 4.2.3. See [formal-verification-methods-in-netsuke.md §Command placeholder contract](formal-verification-methods-in-netsuke.md#command-placeholder-contract). - - [ ] Add a "Security and command interpolation" section to the README. - - [ ] State the supported placeholders explicitly. - - [ ] State the current backtick-handling boundary explicitly. - - [ ] State whether `shlex::split` is part of the semantic acceptance + - [x] Add a "Security and command interpolation" section to the README. + - [x] State the supported placeholders explicitly. + - [x] State the current backtick-handling boundary explicitly. + - [x] State whether `shlex::split` is part of the semantic acceptance contract. + Completed: [ADR-027](adr-027-command-placeholder-contract.md) records the + contract; the English README and all six translations carry it, with + executable examples and regression tests. - [ ] 4.4.2. Document which dependency kinds participate in cycle detection in the user guide. Requires 4.2.1. See [formal-verification-methods-in-netsuke.md §Cycle-participation contract](formal-verification-methods-in-netsuke.md#cycle-participation-contract). diff --git a/scripts/check-readme-parity.sh b/scripts/check-readme-parity.sh new file mode 100755 index 000000000..a39f13b6e --- /dev/null +++ b/scripts/check-readme-parity.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Compare README heading counts and level order; translation meaning needs review. +# Run manually from any directory. This check is deliberately not a CI gate. +set -euo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." +expected='' +status=0 +for file in README.md README.de.md README.es.md README.fr.md \ + README.ja.md README.pt-BR.md README.zh-CN.md; do + structure=$(awk ' + { + sub(/\r$/, "", $0) + line = $0 + sub(/^ */, "", line) + indentation = length($0) - length(line) + if (indentation <= 3 && match(line, /^(```+|~~~+)/)) { + delimiter = substr(line, 1, 1) + width = RLENGTH + suffix = substr(line, width + 1) + if (!fenced) { + # Backtick fence info strings cannot contain backticks. + if (delimiter != "`" || index(suffix, "`") == 0) { + fenced = 1 + fence_delimiter = delimiter + fence_width = width + } + } else if (delimiter == fence_delimiter && + width >= fence_width && suffix ~ /^[ \t]*$/) { + fenced = 0 + } + next + } + } + !fenced && indentation <= 3 && match(line, /^#+/) { + heading = substr(line, 1, RLENGTH) + suffix = substr(line, RLENGTH + 1) + if (length(heading) <= 6 && (suffix == "" || suffix ~ /^[ \t]/)) { + levels = levels separator heading + separator = "," + count++ + } + } + END { printf "%d:%s\n", count, levels } + ' "$file") + printf '%s %s\n' "$file" "$structure" + if [[ "$file" == README.md ]]; then + expected=$structure + elif [[ "$structure" != "$expected" ]]; then + printf 'README heading structure differs: %s\n' "$file" >&2 + status=1 + fi +done +exit "$status" diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 8f771c02a..ec04b2d9b 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -51,10 +51,13 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-windows-help", "guide-windows-help-install", "guide-windows-path", + "readme-backtick-rejection-manifest", "readme-binstall-install", "readme-crates-io-install", "readme-first-build-commands", "readme-first-build-manifest", + "readme-quoted-path-manifest", + "readme-safe-placeholder-manifest", "readme-source-install", "stdlib-fetch-expression", "stdlib-file-tests-manifest", diff --git a/tests/readme_parity_tests.rs b/tests/readme_parity_tests.rs new file mode 100644 index 000000000..09e7b095f --- /dev/null +++ b/tests/readme_parity_tests.rs @@ -0,0 +1,243 @@ +//! Exercise the manual README heading-parity checker with isolated fixtures. + +#![cfg(unix)] + +use anyhow::{Context, Result, ensure}; +use camino::{Utf8Path, Utf8PathBuf}; +use pretty_assertions::assert_eq; +use rstest::rstest; +use std::process::{Command, Output}; +use tempfile::{TempDir, tempdir}; +use test_support::fs as test_fs; + +const READMES: [&str; 7] = [ + "README.md", + "README.de.md", + "README.es.md", + "README.fr.md", + "README.ja.md", + "README.pt-BR.md", + "README.zh-CN.md", +]; + +struct ParityWorkspace { + _directory: TempDir, + root: Utf8PathBuf, +} + +impl ParityWorkspace { + /// Stage the published checker with seven independently editable READMEs. + /// + /// # Errors + /// Return an error if the fixture directory or any file cannot be created. + fn new(source: &str) -> Result { + let directory = tempdir().context("create README parity fixture")?; + let root = Utf8Path::from_path(directory.path()) + .context("temporary README workspace path must be UTF-8")? + .to_path_buf(); + test_fs::create_dir_all(root.join("scripts"))?; + test_fs::copy( + Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-readme-parity.sh"), + root.join("scripts/check-readme-parity.sh"), + )?; + let workspace = Self { + _directory: directory, + root, + }; + for name in READMES { + workspace.write(name, source)?; + } + Ok(workspace) + } + + /// Replace one README to model a translated edition with a different shape. + /// + /// # Errors + /// Return an error if the fixture README cannot be written. + fn write(&self, name: &str, source: &str) -> Result<()> { + test_fs::write(self.root.join(name), source).with_context(|| format!("write {name}")) + } + + /// Execute the checker from the supplied directory without changing process state. + /// + /// # Errors + /// Return an error if Bash cannot be started. + fn run_from(&self, current_dir: &Utf8Path) -> Result { + Command::new("bash") + .arg( + self.root + .join("scripts/check-readme-parity.sh") + .as_std_path(), + ) + .current_dir(current_dir.as_std_path()) + .output() + .context("execute README parity checker") + } + + /// Execute the checker from its fixture project root. + /// + /// # Errors + /// Return an error if Bash cannot be started. + fn run(&self) -> Result { + self.run_from(&self.root) + } +} + +/// Decode UTF-8 output so failures show the checker's actual report. +/// +/// # Errors +/// Return an error if either stream is not valid UTF-8. +fn output_text(output: Output) -> Result<(Option, String, String)> { + Ok(( + output.status.code(), + String::from_utf8(output.stdout).context("checker stdout must be UTF-8")?, + String::from_utf8(output.stderr).context("checker stderr must be UTF-8")?, + )) +} + +#[rstest] +fn matching_headings_use_levels_not_translated_words() -> Result<()> { + let workspace = ParityWorkspace::new("# English\n## Contract\n### Detail\n")?; + workspace.write("README.de.md", "# Deutsch\n## Vertrag\n### Detail\n")?; + let (status, stdout, stderr) = output_text(workspace.run()?)?; + assert_eq!( + status, + Some(0), + "matching heading levels should pass: {stderr}" + ); + assert_eq!(stdout.lines().count(), READMES.len()); + for name in READMES { + ensure!( + stdout.contains(&format!("{name} 3:#,##,###")), + "missing or incorrect structure for {name}: {stdout}" + ); + } + assert_eq!(stderr, ""); + Ok(()) +} + +#[rstest] +#[case::missing_heading("# Title\n", "1:#")] +#[case::changed_level("# Title\n### Detail\n", "2:#,###")] +fn changed_heading_structure_fails(#[case] replacement: &str, #[case] actual: &str) -> Result<()> { + let workspace = ParityWorkspace::new("# Title\n## Detail\n")?; + workspace.write("README.de.md", replacement)?; + let (status, stdout, stderr) = output_text(workspace.run()?)?; + assert_eq!(status, Some(1), "changed heading structure should fail"); + ensure!( + stdout.contains(&format!("README.de.md {actual}")), + "{stdout}" + ); + assert_eq!(stderr, "README heading structure differs: README.de.md\n"); + Ok(()) +} + +#[rstest] +fn reports_each_mismatched_translation() -> Result<()> { + let workspace = ParityWorkspace::new("# Title\n## Detail\n")?; + workspace.write("README.de.md", "# Title\n")?; + workspace.write("README.fr.md", "# Title\n### Detail\n")?; + let (status, _, stderr) = output_text(workspace.run()?)?; + assert_eq!(status, Some(1), "both changed editions should fail"); + assert_eq!( + stderr, + concat!( + "README heading structure differs: README.de.md\n", + "README heading structure differs: README.fr.md\n" + ) + ); + Ok(()) +} + +#[rstest] +#[case::backticks("# Title\n```yaml\n# Hidden\n```\n## Visible\n", "2:#,##")] +#[case::tildes("# Title\n~~~text\n# Hidden\n~~~\n## Visible\n", "2:#,##")] +#[case::short_fence("# Title\n``\n## Visible\n", "2:#,##")] +#[case::different_delimiter("# Title\n```\n# Hidden\n~~~\n# Hidden\n```\n## Visible\n", "2:#,##")] +#[case::longer_closer("# Title\n~~~\n# Hidden\n~~~~\n## Visible\n", "2:#,##")] +#[case::shorter_closer("# Title\n````\n# Hidden\n```\n# Hidden\n````\n## Visible\n", "2:#,##")] +#[case::trailing_text("# Title\n```\n``` note\n# Hidden\n```\n## Visible\n", "2:#,##")] +#[case::invalid_backtick_info("# Title\n```bad`info\n## Visible\n", "2:#,##")] +#[case::unclosed_fence("# Title\n```\n## Hidden\n", "1:#")] +fn fence_boundaries_control_heading_visibility( + #[case] source: &str, + #[case] structure: &str, +) -> Result<()> { + let workspace = ParityWorkspace::new(source)?; + let (status, stdout, stderr) = output_text(workspace.run()?)?; + assert_eq!( + status, + Some(0), + "identical structures should pass: {stderr}" + ); + ensure!( + stdout.contains(&format!("README.md {structure}\n")), + "unexpected fence interpretation: {stdout}" + ); + Ok(()) +} + +#[rstest] +fn heading_indentation_and_separators_match_markdown_boundaries() -> Result<()> { + let workspace = ParityWorkspace::new(concat!( + " # One\n ## Two\n ### Three\n", + " #### Indented code\n\t#### Tab-indented code\n", + "####\tTabbed separator\n#####\n", + "#word\n####### Too many hashes\n" + ))?; + let (status, stdout, stderr) = output_text(workspace.run()?)?; + assert_eq!( + status, + Some(0), + "identical structures should pass: {stderr}" + ); + ensure!( + stdout.contains("README.md 5:#,##,###,####,#####\n"), + "{stdout}" + ); + Ok(()) +} + +#[rstest] +fn crlf_and_lf_inputs_have_the_same_structure() -> Result<()> { + let workspace = + ParityWorkspace::new("# Title\r\n```yaml\r\n# Hidden\r\n```\r\n## Visible\r\n")?; + workspace.write( + "README.de.md", + "# Titel\n```yaml\n# Verborgen\n```\n## Sichtbar\n", + )?; + let (status, stdout, stderr) = output_text(workspace.run()?)?; + assert_eq!( + status, + Some(0), + "line endings should not alter headings: {stderr}" + ); + ensure!(stdout.contains("README.de.md 2:#,##\n"), "{stdout}"); + Ok(()) +} + +#[rstest] +fn finds_readmes_when_invoked_outside_repository() -> Result<()> { + let workspace = ParityWorkspace::new("# Title\n")?; + let other_directory = tempdir().context("create unrelated current directory")?; + let other_path = Utf8Path::from_path(other_directory.path()) + .context("unrelated temporary directory path must be UTF-8")?; + let (status, stdout, stderr) = output_text(workspace.run_from(other_path)?)?; + assert_eq!( + status, + Some(0), + "script-relative lookup should pass: {stderr}" + ); + assert_eq!(stdout.lines().count(), READMES.len()); + Ok(()) +} + +#[rstest] +fn missing_readme_produces_a_nonzero_exit() -> Result<()> { + let workspace = ParityWorkspace::new("# Title\n")?; + test_fs::remove_file(workspace.root.join("README.zh-CN.md"))?; + let (status, _, stderr) = output_text(workspace.run()?)?; + ensure!(status != Some(0), "a missing edition must fail"); + ensure!(stderr.contains("README.zh-CN.md"), "{stderr}"); + Ok(()) +} diff --git a/tests/readme_security_tests.rs b/tests/readme_security_tests.rs new file mode 100644 index 000000000..50cf6bfac --- /dev/null +++ b/tests/readme_security_tests.rs @@ -0,0 +1,263 @@ +//! Pin the README's POSIX command-interpolation contract to executable examples. + +#![cfg(unix)] + +mod documentation_examples; + +use anyhow::{Context, Result, bail, ensure}; +use documentation_examples::{assert_success, documented_example, manifest_workspace}; +use googletest::{assert_that, matchers::contains_substring}; +use mockable::{DefaultEnv, Env}; +use netsuke::{ + ast::Recipe, + ir::{BuildGraph, INS_TOKEN, OUTS_TOKEN}, + manifest, + ninja_gen::{NinjaGenError, RecipeShell, generate_with_shell}, +}; +use pretty_assertions::assert_eq; +use rstest::{fixture, rstest}; +use test_support::{fs as test_fs, netsuke::run_netsuke_in_with_env}; + +/// Load the published accepted manifest for table and boundary controls. +/// +/// # Errors +/// Return an error if the README example is absent or malformed. +#[fixture] +fn safe_manifest() -> Result { + Ok(documented_example("readme-safe-placeholder-manifest")?.body) +} + +/// Return a lowered recipe and graph for the selected shell. +/// +/// Keep this helper local: these tests inspect published examples rather than +/// constructing IR actions that would bypass marker rendering. +/// +/// # Errors +/// Return the original manifest, lowering, or backend error, or report an +/// unexpected action shape. +fn lower_recipe_for_shell(source: &str, shell: RecipeShell) -> Result<(String, BuildGraph)> { + let manifest = manifest::from_str(source)?; + let graph = BuildGraph::from_manifest_for_shell(&manifest, shell)?; + let action = graph + .actions + .values() + .next() + .context("README action is absent")?; + let recipe = match &action.recipe { + Recipe::Command { command } => command.to_string_vec().join("\n"), + Recipe::Script { script } => script.clone(), + Recipe::Rule { .. } => bail!("README action unexpectedly uses a rule"), + }; + Ok((recipe, graph)) +} + +/// Compile a manifest through rendering, lowering, and the POSIX Ninja backend. +/// +/// # Errors +/// Return the original manifest, lowering, or backend error. +fn generate_posix(source: &str) -> Result { + let (_, graph) = lower_recipe_for_shell(source, RecipeShell::Posix)?; + Ok(generate_with_shell(&graph, RecipeShell::Posix)?) +} + +#[rstest] +fn documented_safe_placeholder_manifest_builds(safe_manifest: Result) -> Result<()> { + let source = safe_manifest?; + let ninja = generate_posix(&source)?; + assert_that!(ninja, contains_substring("$$PATH")); + let workspace = manifest_workspace("readme-safe-placeholder-manifest")?; + test_fs::write(workspace.path().join("input.txt"), "README contract\n")?; + let path = DefaultEnv + .string("PATH") + .context("host PATH is required for Ninja")?; + let run = run_netsuke_in_with_env( + workspace.path(), + &[], + &[("PATH", &path), ("NETSUKE_NINJA", "ninja")], + )?; + assert_success(&run, "documented safe placeholder manifest")?; + assert_eq!( + test_fs::read_to_string(workspace.path().join("output.txt"))?, + "README contract\n" + ); + Ok(()) +} + +#[rstest] +#[case::inputs("{{ ins }}", "input.txt")] +#[case::outputs("{{ outs }}", "output.txt")] +#[case::in_variable("$in", "$$in")] +#[case::out_variable("$out", "$$out")] +#[case::ins_variable("$ins", "$$ins")] +#[case::outs_variable("$outs", "$$outs")] +#[case::input_variable("$input", "$$input")] +#[case::output_variable("$output", "$$output")] +#[case::path_variable("$PATH", "$$PATH")] +fn dollar_forms_are_shell_variables_in_both_recipe_kinds( + safe_manifest: Result, + #[case] form: &str, + #[case] expected: &str, + #[values("command", "script")] kind: &str, +) -> Result<()> { + let source = safe_manifest?.replace( + "command: 'cat {{ ins }} > {{ outs }} && test -n \"$PATH\"'", + &format!("{kind}: 'printf %s {form}'"), + ); + let ninja = generate_posix(&source)?; + let encoded_expected = if kind == "script" && form.starts_with('$') { + format!("\\{expected}") + } else { + expected.to_owned() + }; + assert_that!( + ninja, + contains_substring(format!("printf %s {encoded_expected}")) + ); + Ok(()) +} + +#[rstest] +#[case::comment( + "printf '%s' {{ ins }} > {{ outs }} # {{ ins }} {{ outs }}", + format!("printf '%s' input.txt > output.txt # {INS_TOKEN} {OUTS_TOKEN}") +)] +#[case::heredoc( + "cat < {{ outs }}\n{{ ins }} {{ outs }}\nEOF\nprintf '%s' {{ ins }} > /dev/null", + format!( + "cat < output.txt\n{INS_TOKEN} {OUTS_TOKEN}\nEOF\nprintf '%s' input.txt > /dev/null" + ) +)] +fn inert_script_regions_preserve_rendered_markers( + #[case] recipe: &str, + #[case] expected_lowered: String, + #[values("command", "script")] kind: &str, + #[values(RecipeShell::Posix, RecipeShell::Bash)] shell: RecipeShell, +) -> Result<()> { + let indented_recipe = recipe.replace('\n', "\n "); + let source = safe_manifest()?.replace( + "command: 'cat {{ ins }} > {{ outs }} && test -n \"$PATH\"'", + &format!("{kind}: |\n {indented_recipe}"), + ); + let (lowered, graph) = lower_recipe_for_shell(&source, shell)?; + assert_eq!(lowered.trim_end_matches('\n'), expected_lowered); + if kind == "command" && recipe.contains('\n') { + let error = generate_with_shell(&graph, shell) + .expect_err("a multiline command cannot fit one Ninja binding"); + ensure!( + matches!(error, NinjaGenError::UnsafeNinjaValue), + "expected unsafe Ninja value, got {error:?}" + ); + } else { + let ninja = generate_with_shell(&graph, shell)?; + assert_that!(ninja.as_str(), contains_substring(INS_TOKEN)); + assert_that!(ninja.as_str(), contains_substring(OUTS_TOKEN)); + } + Ok(()) +} + +#[rstest] +fn heredoc_body_marker_remains_literal_when_ninja_runs( + safe_manifest: Result, +) -> Result<()> { + let source = safe_manifest?.replace( + "command: 'cat {{ ins }} > {{ outs }} && test -n \"$PATH\"'", + "script: |\n cat < {{ outs }}\n {{ ins }}\n EOF\n cat {{ ins }} > /dev/null", + ); + let workspace = manifest_workspace("readme-safe-placeholder-manifest")?; + test_fs::write(workspace.path().join("Netsukefile"), source)?; + test_fs::write( + workspace.path().join("input.txt"), + "input path must not appear\n", + )?; + let path = DefaultEnv + .string("PATH") + .context("host PATH is required for Ninja")?; + let run = run_netsuke_in_with_env( + workspace.path(), + &[], + &[("PATH", &path), ("NETSUKE_NINJA", "ninja")], + )?; + assert_success(&run, "README heredoc body marker")?; + assert_eq!( + test_fs::read_to_string(workspace.path().join("output.txt"))?, + format!("{INS_TOKEN}\n") + ); + Ok(()) +} + +#[rstest] +fn netsuke_owned_path_substitutions_are_quoted() -> Result<()> { + let example = documented_example("readme-quoted-path-manifest")?; + let ninja = generate_posix(&example.body)?; + assert_that!( + ninja, + contains_substring("cat input' file.txt' > output.txt") + ); + Ok(()) +} + +#[rstest] +fn documented_backtick_manifest_is_rejected() -> Result<()> { + let workspace = manifest_workspace("readme-backtick-rejection-manifest")?; + let run = run_netsuke_in_with_env( + workspace.path(), + &["--json", "--locale", "en-GB"], + &[ + ("NETSUKE_NINJA", "/no-such-readme-ninja"), + ("LANG", "en_GB.UTF-8"), + ], + )?; + assert_eq!(run.success, false); + assert_that!( + run.stderr, + contains_substring("Invalid command interpolation:") + ); + Ok(()) +} + +#[rstest] +fn balanced_author_backticks_are_accepted(safe_manifest: Result) -> Result<()> { + let source = safe_manifest?.replace("cat {{ ins }}", "echo `printf authored`"); + let ninja = generate_posix(&source)?; + assert_that!(ninja, contains_substring("`printf authored`")); + Ok(()) +} + +#[rstest] +fn odd_backtick_count_without_markers_is_rejected(safe_manifest: Result) -> Result<()> { + let source = safe_manifest?.replace( + "cat {{ ins }} > {{ outs }} && test -n \"$PATH\"", + "echo `authored", + ); + let error = generate_posix(&source).expect_err("an odd backtick count must be rejected"); + assert_that!( + error.to_string(), + contains_substring("Invalid command interpolation:") + ); + Ok(()) +} + +#[rstest] +#[case::command("command", false)] +#[case::script("script", true)] +fn shlex_gate_applies_to_commands_not_scripts( + safe_manifest: Result, + #[case] kind: &str, + #[case] accepted: bool, +) -> Result<()> { + let source = safe_manifest?.replace( + "command: 'cat {{ ins }} > {{ outs }} && test -n \"$PATH\"'", + &format!("{kind}: |\n echo 'unterminated"), + ); + let result = generate_posix(&source); + if accepted { + assert_that!(result?, contains_substring("unterminated")); + } else { + let error = result.expect_err("command quoting must be checked"); + assert_that!( + error.to_string(), + contains_substring("Invalid command interpolation:") + ); + } + Ok(()) +}